[OID4VCI] Migrate OID4VCJWTIssuerEndpointTest

Closes #46925

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2026-03-06 13:36:17 +01:00 committed by Marek Posolda
parent 529c1a9009
commit 5db69aec7d
7 changed files with 2358 additions and 1608 deletions

View file

@ -132,6 +132,11 @@ public class ClientConfigBuilder {
return this;
}
public ClientConfigBuilder optionalClientScopes(String... optionalClientScopes) {
rep.setOptionalClientScopes(Collections.combine(rep.getOptionalClientScopes(), optionalClientScopes));
return this;
}
public ClientConfigBuilder protocolMappers(List<ProtocolMapperRepresentation> mappers) {
rep.setProtocolMappers(Collections.combine(rep.getProtocolMappers(), mappers));
return this;

View file

@ -1,5 +1,6 @@
package org.keycloak.testframework.realm;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@ -11,6 +12,7 @@ import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -70,6 +72,11 @@ public class RealmConfigBuilder {
return UserConfigBuilder.update(user).enabled(true).username(username);
}
public UserConfigBuilder addUser(UserRepresentation user) {
rep.setUsers(Collections.combine(rep.getUsers(), user));
return UserConfigBuilder.update(user);
}
public GroupConfigBuilder addGroup(String name) {
GroupRepresentation group = new GroupRepresentation();
rep.setGroups(Collections.combine(rep.getGroups(), group));
@ -77,13 +84,18 @@ public class RealmConfigBuilder {
}
public RoleConfigBuilder addRole(String name) {
RoleRepresentation role = new RoleRepresentation();
role.setName(name);
return addRole(role);
}
public RoleConfigBuilder addRole(RoleRepresentation roleRepresentation) {
if (rep.getRoles() == null) {
rep.setRoles(new RolesRepresentation());
}
RoleRepresentation role = new RoleRepresentation();
rep.getRoles().setRealm(Collections.combine(rep.getRoles().getRealm(), role));
return RoleConfigBuilder.update(role).name(name);
rep.getRoles().setRealm(Collections.combine(rep.getRoles().getRealm(), roleRepresentation));
return RoleConfigBuilder.update(roleRepresentation).name(roleRepresentation.getName());
}
public RoleConfigBuilder addClientRole(String clientName, String roleName) {
@ -446,6 +458,24 @@ public class RealmConfigBuilder {
return this;
}
public void verifiableCredentialsEnabled(Boolean verifiableCredentialsEnabled) {
rep.setVerifiableCredentialsEnabled(verifiableCredentialsEnabled);
}
public void attribute(String key, String value) {
if (rep.getAttributes() == null) {
rep.setAttributes(new HashMap<>());
}
rep.getAttributes().put(key, value);
}
public void addClientScope(ClientScopeRepresentation clientScope) {
if (rep.getClientScopes() == null) {
rep.setClientScopes(new ArrayList<>());
}
rep.getClientScopes().add(clientScope);
}
/**
* Best practice is to use other convenience methods when configuring a realm, but while the framework is under
* active development there may not be a way to perform all updates required. In these cases this method allows

View file

@ -16,31 +16,13 @@
*/
package org.keycloak.tests.oid4vc;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.keycloak.admin.client.resource.ComponentsResource;
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.common.util.SecretGenerator;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.keys.KeyProvider;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
@ -99,100 +81,4 @@ public class OID4VCIWellKnownProviderTest extends OID4VCIssuerTestBase {
assertTrue(algValuesSupported.contains(RSA_OAEP_256), "The algorithm of the configured asymmetric encryption key should be provided.");
});
}
ComponentRepresentation getAesKeyProvider(String algorithm, String keyName, String keyUse, String providerId) {
// Generate a random AES key (default length: 256 bits)
byte[] secret = SecretGenerator.getInstance().randomBytes(32); // 32 bytes = 256 bits
String secretBase64 = Base64.getEncoder().encodeToString(secret);
ComponentRepresentation component = new ComponentRepresentation();
component.setProviderType(KeyProvider.class.getName());
component.setName(keyName);
component.setId(UUID.randomUUID().toString());
component.setProviderId(providerId);
component.setConfig(new MultivaluedHashMap<>(
Map.of(
"secret", List.of(secretBase64),
"active", List.of("true"),
"priority", List.of(String.valueOf(100)),
"enabled", List.of("true"),
"algorithm", List.of(algorithm),
"keyUse", List.of(keyUse) // encryption usage
)
));
return component;
}
ComponentRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) {
ComponentRepresentation component = new ComponentRepresentation();
component.setProviderType(KeyProvider.class.getName());
component.setName("rsa-key-provider");
component.setId(UUID.randomUUID().toString());
component.setProviderId("rsa");
Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(
new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey");
component.setConfig(new MultivaluedHashMap<>(
Map.of(
"privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())),
"certificate", List.of(PemUtils.encodeCertificate(certificate)),
"active", List.of("true"),
"priority", List.of("0"),
"enabled", List.of("true"),
"algorithm", List.of(keyWrapper.getAlgorithm()),
"keyUse", List.of(keyWrapper.getUse().name())
)
));
return component;
}
ComponentRepresentation getRsaEncKeyProvider(String algorithm, String keyName, int priority) {
ComponentRepresentation component = new ComponentRepresentation();
component.setProviderType(KeyProvider.class.getName());
component.setName(keyName);
component.setId(UUID.randomUUID().toString());
component.setProviderId("rsa");
KeyWrapper keyWrapper = getRsaKey(KeyUse.ENC, algorithm, keyName);
Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(
new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey");
component.setConfig(new MultivaluedHashMap<>(
Map.of(
"privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())),
"certificate", List.of(PemUtils.encodeCertificate(certificate)),
"active", List.of("true"),
"priority", List.of(String.valueOf(priority)),
"enabled", List.of("true"),
"algorithm", List.of(algorithm),
"keyUse", List.of(KeyUse.ENC.name())
)
));
return component;
}
KeyWrapper getRsaKey_Default() {
return getRsaKey(KeyUse.SIG, "RS256", null);
}
KeyWrapper getRsaKey(KeyUse keyUse, String algorithm, String keyName) {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
var keyPair = kpg.generateKeyPair();
KeyWrapper kw = new KeyWrapper();
kw.setPrivateKey(keyPair.getPrivate());
kw.setPublicKey(keyPair.getPublic());
kw.setUse(keyUse);
kw.setKid(keyName != null ? keyName : KeyUtils.createKeyId(keyPair.getPublic()));
kw.setType("RSA");
kw.setAlgorithm(algorithm);
return kw;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,556 @@
/*
* Copyright 2024 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 java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ComponentsResource;
import org.keycloak.common.VerificationException;
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.jose.jws.JWSInput;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServerException;
import org.keycloak.testframework.server.KeycloakUrls;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.validators.PatternValidator;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.BeforeEach;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_VC;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE;
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY;
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN;
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER;
import static org.keycloak.util.JsonSerialization.valueAsPrettyString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
public abstract class OID4VCIssuerEndpointTest extends OID4VCIssuerTestBase {
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpointTest.class);
protected static final TimeProvider TIME_PROVIDER = new StaticTimeProvider(1000);
protected ClientScopeRepresentation sdJwtTypeCredentialClientScope;
protected ClientScopeRepresentation jwtTypeCredentialClientScope;
protected ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
protected CloseableHttpClient httpClient;
protected ClientRepresentation client;
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
record OAuth2CodeEntry(String key, OAuth2Code code) {}
@Override
public void configureTestRealm() {
super.configureTestRealm();
ComponentsResource components = testRealm.admin().components();
components.add(getRsaKeyProvider(getRsaKey_Default())).close();
components.add(getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100)).close();
components.add(getUserProfileProvider());
}
@BeforeEach
public void setup() {
httpClient = HttpClientBuilder.create().build();
client = requireExistingClient(OID4VCI_CLIENT_ID);
sdJwtTypeCredentialClientScope = requireExistingClientScope(sdJwtTypeCredentialScopeName);
jwtTypeCredentialClientScope = requireExistingClientScope(jwtTypeCredentialScopeName);
minimalJwtTypeCredentialClientScope = requireExistingClientScope(minimalJwtTypeCredentialScopeName);
}
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());
OAuth2Code oauth2Code = new OAuth2Code(
SecretGenerator.getInstance().randomString(),
Time.currentTime() + 6000,
SecretGenerator.getInstance().randomString(),
CREDENTIAL_OFFER_URI_CODE_SCOPE,
authenticatedClientSessionModel.getUserSession().getId()
);
String nonce = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oauth2Code);
authenticatedClientSessionModel.setNote(nonce, note);
return new OAuth2CodeEntry(nonce, oauth2Code);
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(
KeycloakSession session,
AppAuthManager.BearerTokenAuthenticator authenticator) {
JwtCredentialBuilder jwtCredentialBuilder = new JwtCredentialBuilder(TIME_PROVIDER, session);
SdJwtCredentialBuilder sdJwtCredentialBuilder = new SdJwtCredentialBuilder();
Map<String, CredentialBuilder> credentialBuilders = Map.of(
jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder
);
return prepareIssuerEndpoint(session, authenticator, credentialBuilders);
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(
KeycloakSession session,
AppAuthManager.BearerTokenAuthenticator authenticator,
Map<String, CredentialBuilder> credentialBuilders) {
return new OID4VCIssuerEndpoint(
session,
credentialBuilders,
authenticator,
TIME_PROVIDER,
30
);
}
protected ClientScopeRepresentation createOptionalClientScope(
String scopeName,
String issuerDid,
String credentialConfigurationId,
String credentialIdentifier,
String vct,
String format,
String protocolMapperReferenceFile,
List<String> acceptedKeyAttestationValues) {
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName(scopeName);
clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
Map<String, String> attributes = new HashMap<>();
attributes.put(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true");
attributes.put(CredentialScopeModel.VC_EXPIRY_IN_SECONDS, "15");
if (issuerDid != null) {
attributes.put(CredentialScopeModel.VC_ISSUER_DID, issuerDid);
}
if (credentialConfigurationId != null) {
attributes.put(CredentialScopeModel.VC_CONFIGURATION_ID, credentialConfigurationId);
}
if (credentialIdentifier != null) {
attributes.put(CredentialScopeModel.VC_IDENTIFIER, credentialIdentifier);
}
if (format != null) {
attributes.put(CredentialScopeModel.VC_FORMAT, format);
}
attributes.put(CredentialScopeModel.VCT, Optional.ofNullable(vct).orElse(credentialIdentifier));
if (credentialConfigurationId != null) {
try {
String vcDisplay = JsonSerialization.writeValueAsString(List.of(
new DisplayObject().setName(credentialConfigurationId).setLocale("en-EN"),
new DisplayObject().setName(credentialConfigurationId).setLocale("de-DE")
));
attributes.put(CredentialScopeModel.VC_DISPLAY, vcDisplay);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
if (acceptedKeyAttestationValues != null) {
attributes.put(CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED, "true");
if (!acceptedKeyAttestationValues.isEmpty()) {
String joinedValues = String.join(",", acceptedKeyAttestationValues);
attributes.put(CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED_KEY_STORAGE, joinedValues);
attributes.put(CredentialScopeModel.VC_KEY_ATTESTATION_REQUIRED_USER_AUTH, joinedValues);
}
}
clientScope.setAttributes(attributes);
List<ProtocolMapperRepresentation> protocolMappers;
if (protocolMapperReferenceFile == null) {
protocolMappers = getProtocolMappers(scopeName);
} else {
protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile);
protocolMappers.add(getStaticClaimMapper(scopeName));
}
clientScope.setProtocolMappers(protocolMappers);
return clientScope;
}
protected ClientScopeRepresentation registerOptionalClientScope(ClientScopeRepresentation clientScope) {
// Automatically removed when a test method is finished.
try (Response res = testRealm.admin().clientScopes().create(clientScope)) {
String scopeId = ApiUtil.getCreatedId(res);
testRealm.cleanup().add(realm -> realm.clientScopes().get(scopeId).remove());
clientScope.setId(scopeId);
}
return clientScope;
}
protected ClientRepresentation requireExistingClient(String clientId) {
return testRealm.admin().clients().findByClientId(clientId).stream()
.findFirst()
.orElseThrow(() -> new AssertionError("No such client: " + clientId));
}
protected ClientScopeRepresentation requireExistingClientScope(String scopeName) {
// Check if the client scope already exists
return testRealm.admin().clientScopes().findAll().stream()
.filter(scope -> scope.getName().equals(scopeName))
.findFirst()
.orElseThrow(() -> new AssertionError("No such client scope: " + scopeName));
}
public static List<ProtocolMapperRepresentation> resolveProtocolMappers(String protocolMapperReferenceFile) {
if (protocolMapperReferenceFile == null) {
return null;
}
try (InputStream inputStream = OID4VCIssuerEndpointTest.class.getResourceAsStream(protocolMapperReferenceFile)) {
return JsonSerialization.mapper.readValue(inputStream, ClientScopeRepresentation.class).getProtocolMappers();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// Tests the AuthZCode complete flow without scope from
// 1. Get authorization code without scope specified by wallet
// 2. Using the code to get access token
// 3. Get the credential configuration id from issuer metadata at .wellKnown
// 4. With the access token, get the credential
protected void testCredentialIssuanceWithAuthZCodeFlow(ClientScopeRepresentation credScope,
Function<String, String> f,
Consumer<Map<String, Object>> c) {
// Use credential_identifier if available, otherwise use configuration_id for error testing
String testCredentialConfigurationId = credScope.getAttributes().get(VC_CONFIGURATION_ID);
testCredentialIssuanceWithAuthZCodeFlow(credScope, f, c, (credentialIdentifier) -> {
CredentialRequest request = new CredentialRequest();
if (credentialIdentifier != null) {
request.setCredentialIdentifier(credentialIdentifier);
} else {
request.setCredentialConfigurationId(testCredentialConfigurationId);
}
return request;
});
}
protected void testCredentialIssuanceWithAuthZCodeFlow(
ClientScopeRepresentation clientScope,
Function<String, String> f,
Consumer<Map<String, Object>> c,
Function<String, CredentialRequest> crf) {
String testScope = clientScope.getName();
String testFormat = clientScope.getAttributes().get(CredentialScopeModel.VC_FORMAT);
String testCredentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID);
// Use credential_identifier if available, otherwise use configuration_id for error testing
if (crf == null) {
crf = (credentialIdentifier) -> {
CredentialRequest request = new CredentialRequest();
if (credentialIdentifier != null) {
request.setCredentialIdentifier(credentialIdentifier);
} else {
request.setCredentialConfigurationId(testCredentialConfigurationId);
}
return request;
};
}
try (Client restClient = Keycloak.getClientProvider().newRestEasyClient(null, null, true)) {
String metadataUrl = getRealmMetadataPath(testRealm.getName());
WebTarget oid4vciDiscoveryTarget = restClient.target(metadataUrl);
// 1. Get authorization code without scope specified by wallet
// 2. Using the code to get the AccessToken
String token = f.apply(testScope);
// Extract credential_identifier from the token (client-side parsing)
String credentialIdentifier = null;
try {
JsonWebToken jwt = new JWSInput(token).readJsonContent(JsonWebToken.class);
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authDetails != null) {
List<OID4VCAuthorizationDetail> authDetailsResponse = JsonSerialization.readValue(
JsonSerialization.writeValueAsString(authDetails),
new TypeReference<>() {}
);
if (!authDetailsResponse.isEmpty() &&
authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to extract credential_identifier from token", e);
}
// 3. Get the credential configuration id from issuer metadata at .wellKnown
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(
discoveryResponse.readEntity(String.class),
CredentialIssuer.class
);
assertEquals(200, discoveryResponse.getStatus());
assertEquals(getRealmPath(testRealm.getName()), oid4vciIssuerConfig.getCredentialIssuer());
assertEquals(getBasePath(testRealm.getName()) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
// 4. With the access token, get the credential
try (Client clientForCredentialRequest = Keycloak.getClientProvider().newRestEasyClient(null, null, true)) {
UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint());
URI credentialUri = credentialUriBuilder.build();
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
CredentialRequest request = crf.apply(credentialIdentifier);
assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat());
assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
c.accept(Map.of(
"accessToken", token,
"credentialTarget", credentialTarget,
"credentialRequest", request
));
}
}
} catch (IOException | AssertionError e) {
throw new RuntimeException(e);
}
}
protected String getBasePath(String realm) {
return getRealmPath(realm) + "/protocol/oid4vc/";
}
protected String getRealmPath(String realm) {
return keycloakUrls.getBaseUrl() + "/realms/" + realm;
}
protected String getRealmMetadataPath(String realm) {
return keycloakUrls.getBaseUrl() + "/.well-known/openid-credential-issuer/realms/" + realm;
}
protected void requestCredentialWithIdentifier(
String token,
String credentialEndpoint,
String credentialIdentifier,
CredentialResponseHandler responseHandler,
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
CredentialRequest request = new CredentialRequest();
request.setCredentialIdentifier(credentialIdentifier);
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request), ContentType.APPLICATION_JSON);
HttpPost postCredential = new HttpPost(credentialEndpoint);
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.setEntity(stringEntity);
CredentialResponse credentialResponse;
try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
}
// Use response handler to customize checks based on formats.
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
}
public CredentialIssuer getCredentialIssuerMetadata() {
CredentialIssuerMetadataResponse metadataResponse = oauth.oid4vc().doIssuerMetadataRequest();
return metadataResponse.getMetadata();
}
private ComponentRepresentation getUserProfileProvider() {
// Add the User DID attribute, with the same logic as in DeclarativeUserProfileProviderFactory
UPConfig profileConfig = UPConfigUtils.parseSystemDefaultConfig();
if (profileConfig.getAttribute(UserModel.DID) == null) {
UPAttribute attr = new UPAttribute(UserModel.DID);
attr.setDisplayName("${did}");
attr.setPermissions(new UPAttributePermissions(Set.of(ROLE_ADMIN, ROLE_USER), Set.of(ROLE_ADMIN, ROLE_USER)));
attr.setValidations(Map.of(
PatternValidator.ID, Map.of(
"pattern", "^did:.+:.+$",
"error-message", "Value must start with 'did:scheme:'"
)
));
profileConfig.addOrReplaceAttribute(attr);
}
ComponentRepresentation componentRepresentation = new ComponentRepresentation();
componentRepresentation.setId(UUID.randomUUID().toString());
componentRepresentation.setProviderId("declarative-user-profile");
componentRepresentation.setProviderType(UserProfileProvider.class.getName());
componentRepresentation.setConfig(new MultivaluedHashMap<>(
Map.of(UP_COMPONENT_CONFIG_KEY, List.of(JsonSerialization.valueAsString(profileConfig)))
));
return componentRepresentation;
}
protected void withCausePropagation(Runnable r) throws Throwable {
try {
r.run();
} catch (Exception e) {
if (e instanceof RunOnServerException) {
throw e.getCause();
}
throw e;
}
}
protected static class CredentialResponseHandler {
final Logger log = Logger.getLogger(OID4VCIssuerEndpointTest.class);
protected void handleCredentialResponse(CredentialResponse credentialResponse, ClientScopeRepresentation clientScope) throws VerificationException {
assertNotNull(credentialResponse.getCredentials(), "The credentials array should be present in the response.");
assertFalse(credentialResponse.getCredentials().isEmpty(), "The credentials array should not be empty.");
// Get the first credential from the array (maintaining compatibility with single credential tests)
CredentialResponse.Credential credentialObj = credentialResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null.");
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
Map<String, Object> otherClaims = jsonWebToken.getOtherClaims();
log.infof("JsonWebToken: %s", valueAsPrettyString(jsonWebToken));
assertNotNull(jsonWebToken.getId(), "Expected jti claim");
assertNotNull(jsonWebToken.getExp(), "Expected exp claim");
assertNotNull(jsonWebToken.getNbf(), "Expected nbf claim");
assertNotNull(jsonWebToken.getIssuer(), "Expected iss claim");
assertNotNull(jsonWebToken.getSubject(), "Expected sub claim");
assertNull(jsonWebToken.getAudience(), "Unexpected aud claim");
assertNull(jsonWebToken.getIat(), "Unexpected iat claim");
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
assertEquals(Set.of(CLAIM_NAME_VC), otherClaims.keySet());
@SuppressWarnings("unchecked")
Map<String, ?> vc = (Map<String, ?>) otherClaims.get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
Map<String, ?> subClaims = credential.getCredentialSubject().getClaims();
assertNotNull(credential.getIssuanceDate(), "Expected vc.issuanceDate claim");
assertNotNull(credential.getExpirationDate(), "Expected vc.expirationDate claim");
assertNotNull(credential.getContext(), "Expected vc.@context claim");
assertEquals(List.of(clientScope.getName()), credential.getType(), "vc.type mapped correctly");
assertEquals("did:web:test.org", jsonWebToken.getIssuer(), "iss mapped correctly");
assertEquals(URI.create("did:web:test.org"), credential.getIssuer(), "vc.issuer mapped correctly");
assertEquals(jsonWebToken.getSubject(), subClaims.get("id"), "vc.credentialSubject.id mapped correctly");
assertEquals("John", subClaims.get("given_name"), "vc.credentialSubject.given_name mapped correctly");
assertEquals("john@email.cz", subClaims.get("email"), "vc.credentialSubject.email mapped correctly");
assertEquals(clientScope.getName(), subClaims.get("scope-name"), "vc.credentialSubject.scope-name mapped correctly");
assertThat("vc.credentialSubject.address is parent claim for nested claims", subClaims.get("address"), instanceOf(Map.class));
@SuppressWarnings("unchecked")
Map<String, ?> nestedAddressClaim = (Map<String, ?>) subClaims.get("address");
assertThat("vc.credentialSubject.address contains two nested claims", nestedAddressClaim, aMapWithSize(2));
assertEquals("221B Baker Street", nestedAddressClaim.get("street_address"), "vc.credentialSubject.address.street_address mapped correctly");
assertEquals("London", nestedAddressClaim.get("locality"), "vc.credentialSubject.address.locality mapped correctly");
assertFalse(subClaims.containsKey("AnotherCredentialType"), "Unexpected other claim");
}
}
}

View file

@ -3,7 +3,16 @@ package org.keycloak.tests.oid4vc;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@ -11,38 +20,46 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import jakarta.ws.rs.core.Response;
import org.keycloak.OID4VCConstants;
import org.keycloak.VCFormat;
import org.keycloak.admin.client.CreatedResponseUtil;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientScopesResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.common.Profile;
import org.keycloak.common.crypto.CryptoIntegration;
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.common.util.SecretGenerator;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.keys.KeyProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsParser;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
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;
import org.keycloak.testframework.annotations.InjectDependency;
import org.keycloak.testframework.annotations.InjectClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
@ -51,9 +68,10 @@ import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.server.KeycloakUrls;
import org.keycloak.testframework.ui.annotations.InjectWebDriver;
import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;
import org.keycloak.testsuite.util.oauth.AccessTokenRequest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.AuthorizationDetailsParser;
import org.keycloak.util.JsonSerialization;
@ -64,6 +82,8 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;
/**
* Abstract base class for OID4VCI Testing
@ -73,23 +93,30 @@ import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
*/
public abstract class OID4VCIssuerTestBase {
final Logger log = Logger.getLogger(getClass());
protected final Logger log = Logger.getLogger(getClass());
public static final String OID4VCI_CLIENT_ID = "oid4vci-client";
static final URI ISSUER_DID = URI.create("did:web:test.org");
static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json";
protected static final String sdJwtCredentialVct = "https://credentials.example.com/SD-JWT-Credential";
protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential";
protected static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id";
static final String jwtTypeCredentialScopeName = "jwt-credential";
static final String jwtTypeCredentialConfigurationIdName = "jwt-credential-config-id";
static final String minimalJwtTypeCredentialScopeName = "vc-with-minimal-config";
static final String minimalJwtTypeCredentialScopeIdName = "vc-with-minimal-config-id";
CredentialScopeRepresentation jwtTypeCredentialScope;
CredentialScopeRepresentation minimalJwtTypeCredentialScope;
CredentialScopeRepresentation jwtTypeCredentialScope;
CredentialScopeRepresentation sdJwtTypeCredentialScope;
@InjectRealm(config = VCTestRealmConfig.class)
ManagedRealm testRealm;
@InjectAdminClient
Keycloak keycloak;
@InjectClient(ref = "oid4vci-client", config = OID4VCIClient.class)
ManagedClient managedClient;
@InjectOAuthClient
OAuthClient oauth;
@ -100,114 +127,32 @@ public abstract class OID4VCIssuerTestBase {
@InjectWebDriver
ManagedWebDriver driver;
String clientId = "test-app";
ClientRepresentation client;
@InjectAdminClient
Keycloak keycloak;
@TestSetup
public void configureTestRealm() {
// Enable OID4VCI on the test realm
//
RealmResource realmResource = testRealm.admin();
RealmRepresentation realm = realmResource.toRepresentation();
realm.setVerifiableCredentialsEnabled(shouldEnableOid4vci(realm));
realmResource.update(realm);
// Add a user representations
//
UsersResource usersResource = realmResource.users();
for (UserRepresentation user : List.of(
getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), Map.of()),
getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()),
getUserRepresentation("Bob Baumeister", Map.of("did", "did:key:789"), List.of(), Map.of()))) {
try (Response res = usersResource.create(user)) {
String userId = CreatedResponseUtil.getCreatedId(res);
UserResource userResource = usersResource.get(userId);
List<RoleRepresentation> userRealmRoles = testRealm.admin().roles().list().stream()
.filter(it -> user.getRealmRoles().contains(it.getName()))
.toList();
userResource.roles().realmLevel().add(userRealmRoles);
}
}
// Register Credential Scopes
//
ClientScopesResource clientScopesResource = realmResource.clientScopes();
clientScopesResource.create(createCredentialScope(jwtTypeCredentialScopeName,
ISSUER_DID.toString(),
jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName,
null,
VCFormat.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE,
Collections.emptyList())
).close();
clientScopesResource.create(createCredentialScope(minimalJwtTypeCredentialScopeName,
null,
null,
null,
null,
null,
null,
null)
).close();
jwtTypeCredentialScope = requireExistingCredentialScope(jwtTypeCredentialScopeName);
minimalJwtTypeCredentialScope = requireExistingCredentialScope(minimalJwtTypeCredentialScopeName);
// Update the test clients
//
ClientsResource clientsResource = realmResource.clients();
for (String cid : List.of(clientId)) {
ClientRepresentation client = clientsResource.findByClientId(cid).get(0);
ClientResource clientResource = clientsResource.get(client.getId());
// Enable OID4VCI
setOid4vciEnabled(client, shouldEnableOid4vci(client));
clientResource.update(client);
// Assign optional client scopes
clientResource.addOptionalClientScope(jwtTypeCredentialScope.getId());
clientResource.addOptionalClientScope(minimalJwtTypeCredentialScope.getId());
}
// Fetch the test client
client = clientsResource.findByClientId(clientId).get(0);
UPConfig upConfig = realmResource.users().userProfile().getConfiguration();
upConfig.setUnmanagedAttributePolicy(UPConfig.UnmanagedAttributePolicy.ADMIN_EDIT);
realmResource.users().userProfile().update(upConfig);
AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsParser());
}
@BeforeEach
void beforeEachInternal() {
client = testRealm.admin().clients().findByClientId(clientId).get(0);
client = managedClient.admin().toRepresentation();
jwtTypeCredentialScope = requireExistingCredentialScope(jwtTypeCredentialScopeName);
minimalJwtTypeCredentialScope = requireExistingCredentialScope(minimalJwtTypeCredentialScopeName);
sdJwtTypeCredentialScope = requireExistingCredentialScope(sdJwtTypeCredentialScopeName);
oauth.client(OID4VCI_CLIENT_ID, "test-secret");
}
protected boolean shouldEnableOid4vci(RealmRepresentation realm) {
return true;
}
protected boolean shouldEnableOid4vci(ClientRepresentation client) {
return true;
}
boolean isOid4vciEnabled(ClientRepresentation client) {
Map<String, String> attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>());
return Boolean.parseBoolean(attributes.get(OID4VCI_ENABLED_ATTRIBUTE_KEY));
}
void setOid4vciEnabled(ClientRepresentation client, boolean enable) {
Map<String, String> attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>());
attributes.put(OID4VCI_ENABLED_ATTRIBUTE_KEY, String.valueOf(enable));
client.setAttributes(attributes);
}
CredentialScopeRepresentation createCredentialScope(
public static CredentialScopeRepresentation createCredentialScope(
String scopeName,
String issuerDid,
String credentialConfigurationId,
@ -229,7 +174,8 @@ public abstract class OID4VCIssuerTestBase {
if (credentialConfigurationId != null) {
List<DisplayObject> displayObjects = List.of(
new DisplayObject().setName(credentialConfigurationId).setLocale("en-EN"),
new DisplayObject().setName(credentialConfigurationId).setLocale("de-DE"));
new DisplayObject().setName(credentialConfigurationId).setLocale("de-DE")
);
cs.setDisplay(JsonSerialization.valueAsString(displayObjects));
}
@ -244,7 +190,7 @@ public abstract class OID4VCIssuerTestBase {
if (protocolMapperReferenceFile == null) {
cs.setProtocolMappers(getProtocolMappers(scopeName));
} else {
List<ProtocolMapperRepresentation> protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile);
List<ProtocolMapperRepresentation> protocolMappers = new ArrayList<>(resolveProtocolMappers(protocolMapperReferenceFile));
protocolMappers.add(getStaticClaimMapper(scopeName));
cs.setProtocolMappers(protocolMappers);
}
@ -256,7 +202,8 @@ public abstract class OID4VCIssuerTestBase {
return testRealm.admin().clientScopes().findAll().stream()
.filter(it -> scopeName.equals(it.getName()))
.map(CredentialScopeRepresentation::new)
.findFirst().orElse(null);
.findFirst()
.orElse(null);
}
CredentialScopeRepresentation requireExistingCredentialScope(String scopeName) {
@ -264,7 +211,7 @@ public abstract class OID4VCIssuerTestBase {
.orElseThrow(() -> new IllegalStateException("No such credential scope: " + scopeName));
}
ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
public static 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);
@ -283,7 +230,7 @@ public abstract class OID4VCIssuerTestBase {
return protocolMapperRepresentation;
}
ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("generated-id-mapper");
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
@ -293,7 +240,7 @@ public abstract class OID4VCIssuerTestBase {
return protocolMapperRepresentation;
}
List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
public static List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
return List.of(
getSubjectIdMapper(CLAIM_NAME_SUBJECT_ID, UserModel.DID),
getUserAttributeMapper("email", "email"),
@ -307,7 +254,7 @@ public abstract class OID4VCIssuerTestBase {
getIssuedAtTimeMapper("nbf", null, "COMPUTE"));
}
ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName) {
public static ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(attributeName + "-mapper");
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
@ -319,7 +266,7 @@ public abstract class OID4VCIssuerTestBase {
return protocolMapperRepresentation;
}
ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
@ -332,7 +279,7 @@ public abstract class OID4VCIssuerTestBase {
return protocolMapperRepresentation;
}
ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
public static ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(attributeName + "-mapper");
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
@ -346,16 +293,17 @@ public abstract class OID4VCIssuerTestBase {
return protocolMapperRepresentation;
}
UserRepresentation getUserRepresentation(
public static UserRepresentation getUserRepresentation(
String fullName,
Map<String, String> attributes,
List<String> realmRoles,
Map<String, List<String>> clientRoles
) {
Map<String, List<String>> clientRoles) {
String[] nameToks = fullName.split("\\s");
String firstName = nameToks[0];
String lastName = nameToks[1];
String lastName = nameToks.length > 1 ? nameToks[1] : "";
String username = firstName.toLowerCase();
UserConfigBuilder userBuilder = UserConfigBuilder.create()
.id(KeycloakModelUtils.generateId())
.username(username)
@ -372,27 +320,171 @@ public abstract class OID4VCIssuerTestBase {
attributes.forEach(userBuilder::attribute);
// 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::roles);
clientRoles.forEach((cid, roles) -> roles.forEach(role -> userBuilder.roles(cid, role)));
return userBuilder.build();
}
List<ProtocolMapperRepresentation> resolveProtocolMappers(String protocolMapperReferenceFile) {
public static List<ProtocolMapperRepresentation> resolveProtocolMappers(String protocolMapperReferenceFile) {
if (protocolMapperReferenceFile == null) {
return null;
}
try (InputStream inputStream = getClass().getResourceAsStream(protocolMapperReferenceFile)) {
return JsonSerialization.mapper.readValue(inputStream,
ClientScopeRepresentation.class).getProtocolMappers();
try (InputStream inputStream = OID4VCIssuerTestBase.class.getResourceAsStream(protocolMapperReferenceFile)) {
return JsonSerialization.mapper.readValue(inputStream, ClientScopeRepresentation.class).getProtocolMappers();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static class StaticTimeProvider implements TimeProvider {
private final int currentTimeInS;
public StaticTimeProvider(int currentTimeInS) {
this.currentTimeInS = currentTimeInS;
}
@Override
public int currentTimeSeconds() {
return currentTimeInS;
}
@Override
public long currentTimeMillis() {
return currentTimeInS * 1000L;
}
}
public static KeyWrapper getRsaKey(KeyUse keyUse, String algorithm, String keyName) {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
var keyPair = kpg.generateKeyPair();
KeyWrapper kw = new KeyWrapper();
kw.setPrivateKey(keyPair.getPrivate());
kw.setPublicKey(keyPair.getPublic());
kw.setUse(keyUse);
kw.setKid(keyName != null ? keyName : KeyUtils.createKeyId(keyPair.getPublic()));
kw.setType("RSA");
kw.setAlgorithm(algorithm);
return kw;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
public static ComponentRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) {
return createRsaKeyProviderComponent(keyWrapper, "rsa-key-provider", 0);
}
public static ComponentRepresentation getRsaEncKeyProvider(String algorithm, String keyName, int priority) {
KeyWrapper keyWrapper = getRsaKey(KeyUse.ENC, algorithm, keyName);
return createRsaKeyProviderComponent(keyWrapper, keyName, priority);
}
private static ComponentRepresentation createRsaKeyProviderComponent(KeyWrapper keyWrapper, String name, int priority) {
ComponentRepresentation component = new ComponentRepresentation();
component.setProviderType(KeyProvider.class.getName());
component.setName(name);
component.setId(UUID.randomUUID().toString());
component.setProviderId("rsa");
Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(
new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey");
component.setConfig(new MultivaluedHashMap<>(Map.of(
"privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())),
"certificate", List.of(PemUtils.encodeCertificate(certificate)),
"active", List.of("true"),
"priority", List.of(String.valueOf(priority)),
"enabled", List.of("true"),
"algorithm", List.of(keyWrapper.getAlgorithm()),
"keyUse", List.of(keyWrapper.getUse().name())
)));
return component;
}
public static KeyWrapper getRsaKey_Default() {
return getRsaKey(KeyUse.SIG, "RS256", null);
}
public static ComponentRepresentation getAesKeyProvider(String algorithm, String keyName, String keyUse, String providerId) {
// Generate a random AES key (default length: 256 bits)
byte[] secret = SecretGenerator.getInstance().randomBytes(32); // 32 bytes = 256 bits
String secretBase64 = Base64.getEncoder().encodeToString(secret);
ComponentRepresentation component = new ComponentRepresentation();
component.setProviderType(KeyProvider.class.getName());
component.setName(keyName);
component.setId(UUID.randomUUID().toString());
component.setProviderId(providerId);
component.setConfig(new MultivaluedHashMap<>(Map.of(
"secret", List.of(secretBase64),
"active", List.of("true"),
"priority", List.of(String.valueOf(100)),
"enabled", List.of("true"),
"algorithm", List.of(algorithm),
"keyUse", List.of(keyUse) // encryption usage
)));
return component;
}
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, String scope) {
return getBearerToken(oauthClient, client, "john", scope);
}
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
return getBearerTokenCodeFlow(oauthClient, client, username, scope).getAccessToken();
}
protected AccessTokenResponse getBearerTokenCodeFlow(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
var authCode = getAuthorizationCode(oauthClient, client, username, scope);
return oauthClient.accessTokenRequest(authCode).send();
}
protected String getAuthorizationCode(OAuthClient oAuthClient, ClientRepresentation client, String username, String scope) {
if (client != null) {
if (client.getSecret() != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
else {
oAuthClient.client(client.getClientId());
}
}
if (scope != null) {
oAuthClient.scope(scope);
}
var authorizationEndpointResponse = oAuthClient.doLogin(username, "password");
return authorizationEndpointResponse.getCode();
}
protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, OID4VCAuthorizationDetail... authDetail) {
AccessTokenRequest accessTokenRequest = oauthClient.accessTokenRequest(authCode);
if (authDetail != null && authDetail.length > 0) {
accessTokenRequest.authorizationDetails(Arrays.asList(authDetail));
}
AccessTokenResponse tokenResponse = accessTokenRequest.send();
if (!tokenResponse.isSuccess()) {
throw new IllegalStateException(tokenResponse.getErrorDescription());
}
return tokenResponse;
}
// Config ----------------------------------------------------------------------------------------------------------
static class VCTestServerConfig implements KeycloakServerConfig {
@ -406,14 +498,85 @@ public abstract class OID4VCIssuerTestBase {
public static final String TEST_REALM_NAME = "test";
@InjectDependency
KeycloakUrls keycloakUrls;
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
realm.name(TEST_REALM_NAME);
CryptoIntegration.init(this.getClass().getClassLoader());
realm.verifiableCredentialsEnabled(true);
realm.addRole(CREDENTIAL_OFFER_CREATE);
// Allow the default client scopes to be added as well
realm.attribute(CREATE_DEFAULT_CLIENT_SCOPES, String.valueOf(true));
realm.addClientScope(createCredentialScope(
sdJwtTypeCredentialScopeName,
null,
sdJwtTypeCredentialConfigurationIdName,
sdJwtTypeCredentialScopeName,
sdJwtCredentialVct,
VCFormat.SD_JWT_VC,
null,
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH, OID4VCConstants.KeyAttestationResistanceLevels.MODERATE))
);
realm.addClientScope(createCredentialScope(
jwtTypeCredentialScopeName,
ISSUER_DID.toString(),
jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName,
null,
VCFormat.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE,
Collections.emptyList()
));
realm.addClientScope(createCredentialScope(
minimalJwtTypeCredentialScopeName,
null,
minimalJwtTypeCredentialScopeIdName,
null,
minimalJwtTypeCredentialScopeName,
VC_FORMAT_DEFAULT,
null,
null
));
realm.addUser(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), Collections.emptyMap()));
realm.addUser(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()));
return realm;
}
}
public static class OID4VCIClient implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
client.clientId(OID4VCI_CLIENT_ID)
.serviceAccountsEnabled(true)
.directAccessGrantsEnabled(true)
.attribute(OID4VCI_ENABLED_ATTRIBUTE_KEY, "true")
.defaultClientScopes("basic", "profile", "roles")
.optionalClientScopes(jwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, "email")
.redirectUris("*")
.secret("test-secret");
return client;
}
}
protected boolean shouldEnableOid4vci(RealmRepresentation realm) {
return true;
}
protected boolean shouldEnableOid4vci(ClientRepresentation client) {
return true;
}
boolean isOid4vciEnabled(ClientRepresentation client) {
Map<String, String> attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>());
return Boolean.parseBoolean(attributes.get(OID4VCI_ENABLED_ATTRIBUTE_KEY));
}
}