From 5db69aec7d13edfe9a9eea1e32d073d52b7c7cdc Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Fri, 6 Mar 2026 13:36:17 +0100 Subject: [PATCH] [OID4VCI] Migrate OID4VCJWTIssuerEndpointTest Closes #46925 Signed-off-by: Giuseppe Graziano --- .../realm/ClientConfigBuilder.java | 5 + .../realm/RealmConfigBuilder.java | 36 +- .../oid4vc/OID4VCIWellKnownProviderTest.java | 114 -- .../oid4vc/OID4VCIssuerEndpointTest.java | 556 +++++++ .../tests/oid4vc/OID4VCIssuerTestBase.java | 423 +++-- .../oid4vc/OID4VCJWTIssuerEndpointTest.java | 1471 +++++++++++++++++ .../signing/OID4VCJWTIssuerEndpointTest.java | 1361 --------------- 7 files changed, 2358 insertions(+), 1608 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerEndpointTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientConfigBuilder.java index 8b0eb6cd7d1..9f3f7bdbc25 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientConfigBuilder.java @@ -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 mappers) { rep.setProtocolMappers(Collections.combine(rep.getProtocolMappers(), mappers)); return this; diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java index 454885be5e7..57db96a3521 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java @@ -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 diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIWellKnownProviderTest.java index 7d9d945f4dc..6e8cb5776f9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIWellKnownProviderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIWellKnownProviderTest.java @@ -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); - } - } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerEndpointTest.java new file mode 100644 index 00000000000..052dedb67a0 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerEndpointTest.java @@ -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 credentialBuilders = Map.of( + jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder, + sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder + ); + + return prepareIssuerEndpoint(session, authenticator, credentialBuilders); + } + + protected static OID4VCIssuerEndpoint prepareIssuerEndpoint( + KeycloakSession session, + AppAuthManager.BearerTokenAuthenticator authenticator, + Map 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 acceptedKeyAttestationValues) { + + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName(scopeName); + clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + + Map 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 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 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 f, + Consumer> 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 f, + Consumer> c, + Function 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 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 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 vc = (Map) otherClaims.get("vc"); + VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class); + Map 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 nestedAddressClaim = (Map) 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"); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java index d179fcd4dcd..b8d150b4068 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java @@ -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 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 attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>()); - return Boolean.parseBoolean(attributes.get(OID4VCI_ENABLED_ATTRIBUTE_KEY)); - } - - void setOid4vciEnabled(ClientRepresentation client, boolean enable) { - Map 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 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 protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile); + List 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 getProtocolMappers(String scopeName) { + public static List 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 attributes, List realmRoles, - Map> clientRoles - ) { + Map> 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 resolveProtocolMappers(String protocolMapperReferenceFile) { + public static List 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 attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>()); + return Boolean.parseBoolean(attributes.get(OID4VCI_ENABLED_ATTRIBUTE_KEY)); + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java new file mode 100644 index 00000000000..b1ee877390f --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java @@ -0,0 +1,1471 @@ +/* + * 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.net.URI; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.Invocation; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; + +import org.keycloak.TokenVerifier; +import org.keycloak.VCFormat; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.VerificationException; +import org.keycloak.common.util.BouncyIntegration; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.common.util.Time; +import org.keycloak.crypto.ECDSASignatureSignerContext; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.models.RealmModel; +import org.keycloak.models.oid4vci.CredentialScopeModel; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; +import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; +import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator; +import org.keycloak.protocol.oid4vc.model.Claim; +import org.keycloak.protocol.oid4vc.model.ClaimDisplay; +import org.keycloak.protocol.oid4vc.model.Claims; +import org.keycloak.protocol.oid4vc.model.CredentialIssuer; +import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; +import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialsOffer; +import org.keycloak.protocol.oid4vc.model.ErrorResponse; +import org.keycloak.protocol.oid4vc.model.ErrorType; +import org.keycloak.protocol.oid4vc.model.JwtProof; +import org.keycloak.protocol.oid4vc.model.NonceResponse; +import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; +import org.keycloak.protocol.oid4vc.model.Proofs; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.testsuite.util.AccountHelper; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; +import org.keycloak.util.JsonSerialization; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT; +import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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) +public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { + + @InjectRunOnServer + RunOnServerClient runOnServer; + + @AfterEach + public void logout() { + AccountHelper.logout(testRealm.admin(), "john"); + } + + @Test + void testGetCredentialOfferUriUnsupportedCredential() { + 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("inexistent-id") + ); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus(), "Should return BAD_REQUEST"); + }); + } + + @Test + void testGetCredentialOfferUriUnauthorized() { + runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CorsErrorResponseException exception = assertThrows( + CorsErrorResponseException.class, + () -> oid4VCIssuerEndpoint.createCredentialOffer("test-credential", true, "john") + ); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus(), "Should return BAD_REQUEST"); + }); + } + + @Test + void testGetCredentialOfferUriInvalidToken() { + runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString("invalid-token"); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CorsErrorResponseException exception = assertThrows( + CorsErrorResponseException.class, + () -> oid4VCIssuerEndpoint.createCredentialOffer("test-credential", true, "john") + ); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus(), "Should return BAD_REQUEST"); + }); + } + + @Test + public void testGetCredentialOfferURI() { + final String scopeName = jwtTypeCredentialClientScope.getName(); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + String token = getBearerToken(oauth, client, scopeName); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + Response response = oid4VCIssuerEndpoint.createCredentialOffer(credentialConfigurationId); + + assertEquals(HttpStatus.SC_OK, response.getStatus(), "An offer uri should have been returned."); + + CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue( + response.getEntity(), + CredentialOfferURI.class + ); + + assertNotNull(credentialOfferURI.getNonce(), "A nonce should be included."); + assertNotNull(credentialOfferURI.getIssuer(), "The issuer uri should be provided."); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testGetCredentialOfferUnauthorized() { + assertThrows(BadRequestException.class, () -> + withCausePropagation(() -> runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response response = issuerEndpoint.getCredentialOffer("nonce"); + assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType()); + })) + ); + } + + @Test + public void testGetCredentialOfferWithoutNonce() { + String token = getBearerToken(oauth); + assertThrows(BadRequestException.class, () -> + withCausePropagation(() -> runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(null); + })) + ); + } + + @Test + public void testGetCredentialOfferWithoutAPreparedOffer() { + String token = getBearerToken(oauth); + assertThrows(BadRequestException.class, () -> + withCausePropagation(() -> runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer("unpreparedNonce"); + })) + ); + } + + @Test + public void testGetCredentialOfferWithABrokenNote() { + String token = getBearerToken(oauth); + assertThrows(BadRequestException.class, () -> + withCausePropagation(() -> runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String nonce = prepareSessionCode(session, authenticator, "invalidNote").key(); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + issuerEndpoint.getCredentialOffer(nonce); + })) + ); + } + + @Test + public void testGetCredentialOffer() { + String token = getBearerToken(oauth); + + runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + CredentialsOffer credOffer = new CredentialsOffer() + .setCredentialIssuer("the-issuer") + .addGrant(new PreAuthorizedCodeGrant().setPreAuthorizedCode("the-code")) + .setCredentialConfigurationIds(List.of("credential-configuration-id")); + + CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); + CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() + 60, null); + offerStorage.putOfferState(offerState); + + // The cache transactions need to be committed explicitly in the test. + // Without that, the OAuth2Code will only be committed to the cache after .run((session)-> ...) + session.getTransactionManager().commit(); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(offerState.getNonce()); + + assertEquals(HttpStatus.SC_OK, credentialOfferResponse.getStatus(), "The offer should have been returned."); + Object credentialOfferEntity = credentialOfferResponse.getEntity(); + assertNotNull(credentialOfferEntity, "An actual offer should be in the response."); + + CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertEquals(credOffer, retrievedCredentialsOffer, "The offer should be the one prepared with for the session."); + }); + } + + // ----- requestCredential + + @Test + public void testRequestCredentialUnauthorized() { + runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(null); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier("test-credential"); + + String requestPayload; + try { + requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + + CorsErrorResponseException exception = assertThrows( + CorsErrorResponseException.class, + () -> issuerEndpoint.requestCredential(requestPayload) + ); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus(), "Should return BAD_REQUEST"); + }); + } + + @Test + public void testRequestCredentialInvalidToken() { + runOnServer.run(session -> { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString("token"); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier("test-credential"); + + String requestPayload; + try { + requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + + CorsErrorResponseException exception = assertThrows( + CorsErrorResponseException.class, + () -> issuerEndpoint.requestCredential(requestPayload) + ); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), exception.getResponse().getStatus(), "Should return BAD_REQUEST"); + }); + } + + @Test + public void testRequestCredentialNoMatchingCredentialBuilder() throws Throwable { + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + final String scopeName = jwtTypeCredentialClientScope.getName(); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credentialConfigurationId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + + try { + withCausePropagation(() -> runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + // Prepare the issue endpoint with no credential builders. + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + issuerEndpoint.requestCredential(requestPayload); + } catch (IOException e) { + throw new RuntimeException(e); + } + })); + fail("Should have thrown an exception"); + } catch (Exception e) { + assertInstanceOf(BadRequestException.class, e); + assertEquals("No credential builder found for format jwt_vc_json", e.getMessage()); + } + } + + @Test + public void testRequestCredentialUnsupportedCredential() { + String token = getBearerToken(oauth); + + assertThrows(BadRequestException.class, () -> + withCausePropagation(() -> runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier("no-such-credential"); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + issuerEndpoint.requestCredential(requestPayload); + } catch (IOException e) { + throw new RuntimeException(e); + } + })) + ); + } + + @Test + public void testRequestCredential() { + String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + + assertNotNull(authDetailsResponse, "authorization_details should be present in the response"); + assertFalse(authDetailsResponse.isEmpty(), "authorization_details should not be empty"); + + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + assertNotNull(credentialIdentifier, "credential_identifier should be present"); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + Response credentialResponse = issuerEndpoint.requestCredential(requestPayload); + + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatus(), "The credential request should be answered successfully."); + assertNotNull(credentialResponse.getEntity(), "A credential should be responded."); + + CredentialResponse credentialResponseVO = JsonSerialization.mapper + .convertValue(credentialResponse.getEntity(), CredentialResponse.class); + + JsonWebToken jsonWebToken; + try { + jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken(); + } catch (VerificationException e) { + fail("Failed to verify JWT: " + e.getMessage()); + return; + } + + assertNotNull(jsonWebToken, "A valid credential string should have been responded"); + assertNotNull(jsonWebToken.getOtherClaims().get("vc"), "The credentials should be included at the vc-claim."); + + VerifiableCredential credential = JsonSerialization.mapper.convertValue( + jsonWebToken.getOtherClaims().get("vc"), + VerifiableCredential.class + ); + + assertTrue(credential.getCredentialSubject().getClaims().containsKey("scope-name"), "The static claim should be set."); + assertEquals(scopeName, credential.getCredentialSubject().getClaims().get("scope-name"), "The static claim should be set."); + assertFalse(credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"), "Only mappers supported for the requested type should have been evaluated."); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testRequestCredentialWithNeitherIdSet() { + final String scopeName = minimalJwtTypeCredentialClientScope.getName(); + String token = getBearerToken(oauth, client, scopeName); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest(); + + try { + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + issuerEndpoint.requestCredential(requestPayload); + fail("Expected BadRequestException due to missing credential identifier or configuration id"); + } catch (BadRequestException e) { + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST.getValue(), error.getError()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCredentialIssuance() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + + // 1. Retrieving the credential-offer-uri + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialOfferURI credOfferUri = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + + assertNotNull(credOfferUri, "A valid offer uri should be returned"); + + // 2. Using the uri to get the actual credential offer + CredentialOfferResponse credentialOfferResponse = oauth.oid4vc() + .doCredentialOfferRequest(credOfferUri); + CredentialsOffer credentialsOffer = credentialOfferResponse.getCredentialsOffer(); + + assertNotNull(credentialsOffer, "A valid offer should be returned"); + + // 3. Get the issuer metadata + CredentialIssuer credentialIssuer = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(credentialsOffer.getIssuerMetadataUrl()) + .send() + .getMetadata(); + + assertNotNull(credentialIssuer, "Issuer metadata should be returned"); + assertEquals(1, credentialIssuer.getAuthorizationServers().size(), "We only expect one authorization server."); + + // 4. Get the openid-configuration + OIDCConfigurationRepresentation openidConfig = oauth.wellknownRequest() + .url(credentialIssuer.getAuthorizationServers().get(0)) + .send() + .getOidcConfiguration(); + + assertNotNull(openidConfig.getTokenEndpoint(), "A token endpoint should be included."); + assertTrue(openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE), "The pre-authorized code should be supported."); + + // 5. Get an access token for the pre-authorized code + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(credentialsOffer.getPreAuthorizedCode()) + .endpoint(openidConfig.getTokenEndpoint()) + .send(); + + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); + String theToken = accessTokenResponse.getAccessToken(); + assertNotNull(theToken, "Access token should be present"); + + // Extract credential_identifier from authorization_details in token response + List authDetailsResponse = accessTokenResponse.getOID4VCAuthorizationDetails(); + assertNotNull(authDetailsResponse, "authorization_details should be present in the response"); + assertFalse(authDetailsResponse.isEmpty(), "authorization_details should not be empty"); + + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + assertNotNull(credentialIdentifier, "Credential identifier should be present"); + + // 6. Get the credential using credential_identifier (required when authorization_details are present) + credentialsOffer.getCredentialConfigurationIds().stream() + .map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId)) + .map(credConfigId -> credentialIssuer.getCredentialsSupported().get(credConfigId)) + .forEach(supportedCredential -> { + try { + requestCredentialWithIdentifier( + theToken, + credentialIssuer.getCredentialEndpoint(), + credentialIdentifier, + new CredentialResponseHandler(), + jwtTypeCredentialClientScope + ); + } catch (IOException e) { + fail("Was not able to get the credential."); + } catch (VerificationException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { + String scopeName = jwtTypeCredentialClientScope.getName(); + String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + + AccessTokenResponse accessTokenResponse = oauth.accessTokenRequest(authCode).send(); + assertTrue(accessTokenResponse.isSuccess(), "Access token request should succeed"); + assertNotNull(accessTokenResponse.getAccessToken(), "Access token should be present"); + + List authDetailsResponse = accessTokenResponse.getOID4VCAuthorizationDetails(); + assertNotNull(authDetailsResponse, "authorization_details should be present in the token response"); + assertFalse(authDetailsResponse.isEmpty(), "authorization_details should not be empty"); + + OID4VCAuthorizationDetail firstAuthorizationDetail = authDetailsResponse.get(0); + assertEquals(credentialConfigurationId, firstAuthorizationDetail.getCredentialConfigurationId(), "credential_configuration_id should match requested scope"); + assertNotNull(firstAuthorizationDetail.getCredentialIdentifiers(), "credential_identifiers should be present"); + assertFalse(firstAuthorizationDetail.getCredentialIdentifiers().isEmpty(), "credential_identifiers should not be empty"); + + String credentialIdentifier = firstAuthorizationDetail.getCredentialIdentifiers().get(0); + + requestCredentialWithIdentifier( + accessTokenResponse.getAccessToken(), + credentialIssuer.getCredentialEndpoint(), + credentialIdentifier, + new CredentialResponseHandler(), + jwtTypeCredentialClientScope + ); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() { + testCredentialIssuanceWithAuthZCodeFlow( + sdJwtTypeCredentialClientScope, + (testScope) -> getBearerToken(oauth.openid(false).scope("email")), // set registered different scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + } + ); + } + + @Test + public void testCredentialIssuanceWithAuthZCodeSWithoutScope() { + testCredentialIssuanceWithAuthZCodeFlow( + sdJwtTypeCredentialClientScope, + (testScope) -> getBearerToken(oauth.openid(false).scope(null)), // no scope + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { + assertEquals(400, response.getStatus()); + } + } + ); + } + + /** + * When the token contains authorization_details, the credential_identifier from the request must match that in + * the authorization_details from the AccessTokenResponse and in the AccessToken JWT. + */ + @Test + public void testCredentialIssuanceWithScopeUnmatched() { + Function getAccessToken = (testScope) -> + getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + + Consumer> sendCredentialRequest = m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + + try (Response response = credentialTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { + + assertEquals(400, response.getStatus()); + String errorMessage = response.readEntity(String.class); + + assertTrue(errorMessage.contains("unknown_credential_identifier")); + assertTrue(errorMessage.contains("credential_identifier must match one from the authorization_details in the access token")); + } + }; + + testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest, + (cid) -> new CredentialRequest().setCredentialIdentifier("sd-jwt-credential")); + } + + @Test + public void testRequestMultipleCredentialsWithProofs() { + final String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + + String cNonce = getCNonce(); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()); + + String jwtProof1 = generateJwtProof(issuer, cNonce); + String jwtProof2 = generateJwtProof(issuer, cNonce); + Proofs proofs = new Proofs().setJwt(Arrays.asList(jwtProof1, jwtProof2)); + + CredentialRequest request = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier) + .setProofs(proofs); + + OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator); + String requestPayload = JsonSerialization.writeValueAsString(request); + + Response response = endpoint.requestCredential(requestPayload); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), "Response status should be OK"); + + CredentialResponse credentialResponse = JsonSerialization.mapper + .convertValue(response.getEntity(), CredentialResponse.class); + + assertNotNull(credentialResponse, "Credential response should not be null"); + assertNotNull(credentialResponse.getCredentials(), "Credentials array should not be null"); + assertEquals(2, credentialResponse.getCredentials().size(), "Should return 2 credentials due to two proofs"); + + // Validate each credential + for (CredentialResponse.Credential credential : credentialResponse.getCredentials()) { + assertNotNull(credential.getCredential(), "Credential should not be null"); + JsonWebToken jsonWebToken; + try { + jsonWebToken = TokenVerifier.create((String) credential.getCredential(), JsonWebToken.class).getToken(); + } catch (VerificationException e) { + fail("Failed to verify JWT: " + e.getMessage()); + return; + } + + assertNotNull(jsonWebToken, "A valid credential string should be returned"); + assertNotNull(jsonWebToken.getOtherClaims().get("vc"), "The credentials should include the vc claim"); + + VerifiableCredential vc = JsonSerialization.mapper.convertValue( + jsonWebToken.getOtherClaims().get("vc"), + VerifiableCredential.class + ); + + assertTrue(vc.getCredentialSubject().getClaims().containsKey("scope-name"), "The scope-name claim should be set"); + assertEquals(scopeName, vc.getCredentialSubject().getClaims().get("scope-name"), "The scope-name claim should match the scope"); + assertTrue(vc.getCredentialSubject().getClaims().containsKey("given_name"), "The given_name claim should be set"); + assertEquals("John", vc.getCredentialSubject().getClaims().get("given_name"), "The given_name claim should be John"); + assertTrue(vc.getCredentialSubject().getClaims().containsKey("email"), "The email claim should be set"); + assertEquals("john@email.cz", vc.getCredentialSubject().getClaims().get("email"), "The email claim should be john@email.cz"); + assertFalse(vc.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"), "Only supported mappers should be evaluated"); + } + } catch (Exception e) { + throw new RuntimeException("Test failed due to: " + e.getMessage(), e); + } + }); + } + + @Test + public void testGetJwtVcConfigFromMetadata() { + final String scopeName = jwtTypeCredentialClientScope.getName(); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + String expectedIssuer = keycloakUrls.getBase() + "/realms/" + testRealm.getName(); + String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; + String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH; + final String expectedAuthorizationServer = expectedIssuer; + + runOnServer.run(session -> { + OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); + CredentialIssuer credentialIssuer = oid4VCIssuerWellKnownProvider.getIssuerMetadata(); + + assertEquals(expectedIssuer, credentialIssuer.getCredentialIssuer(), "The correct issuer should be included."); + assertEquals(expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint(), "The correct credentials endpoint should be included."); + assertEquals(expectedNonceEndpoint, credentialIssuer.getNonceEndpoint(), "The correct nonce endpoint should be included."); + assertEquals(1, credentialIssuer.getAuthorizationServers().size(), "Since the authorization server is equal to the issuer, just 1 should be returned."); + assertEquals(expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0), "The expected server should have been returned."); + assertTrue(credentialIssuer.getCredentialsSupported().containsKey(credentialConfigurationId), "The jwt_vc-credential should be supported."); + + SupportedCredentialConfiguration jwtVcConfig = credentialIssuer.getCredentialsSupported().get(credentialConfigurationId); + assertEquals(scopeName, jwtVcConfig.getScope(), "The jwt_vc-credential should offer type test-credential"); + assertEquals(VCFormat.JWT_VC, jwtVcConfig.getFormat(), "The jwt_vc-credential should be offered in the jwt_vc format."); + + Claims jwtVcClaims = jwtVcConfig.getCredentialMetadata() != null ? jwtVcConfig.getCredentialMetadata().getClaims() : null; + assertNotNull(jwtVcClaims, "The jwt_vc-credential can optionally provide a claims claim."); + assertEquals(8, jwtVcClaims.size()); + + { + Claim claim = jwtVcClaims.get(0); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.id"); + assertEquals("id", claim.getPath().get(1), "credentialSubject.id mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.id is not mandatory"); + assertNull(claim.getDisplay(), "credentialSubject.id has no display"); + } + { + Claim claim = jwtVcClaims.get(1); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.given_name"); + assertEquals("given_name", claim.getPath().get(1), "credentialSubject.given_name mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.given_name is not mandatory"); + assertNotNull(claim.getDisplay(), "credentialSubject.given_name has display"); + assertEquals(15, claim.getDisplay().size()); + for (ClaimDisplay givenNameDisplay : claim.getDisplay()) { + assertNotNull(givenNameDisplay.getName()); + assertNotNull(givenNameDisplay.getLocale()); + } + } + { + Claim claim = jwtVcClaims.get(2); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.family_name"); + assertEquals("family_name", claim.getPath().get(1), "credentialSubject.family_name mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.family_name is not mandatory"); + assertNotNull(claim.getDisplay(), "credentialSubject.family_name has display"); + assertEquals(15, claim.getDisplay().size()); + for (ClaimDisplay familyNameDisplay : claim.getDisplay()) { + assertNotNull(familyNameDisplay.getName()); + assertNotNull(familyNameDisplay.getLocale()); + } + } + { + Claim claim = jwtVcClaims.get(3); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.birthdate"); + assertEquals("birthdate", claim.getPath().get(1), "credentialSubject.birthdate mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.birthdate is not mandatory"); + assertNotNull(claim.getDisplay(), "credentialSubject.birthdate has display"); + assertEquals(15, claim.getDisplay().size()); + for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { + assertNotNull(birthDateDisplay.getName()); + assertNotNull(birthDateDisplay.getLocale()); + } + } + { + Claim claim = jwtVcClaims.get(4); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.email"); + assertEquals("email", claim.getPath().get(1), "credentialSubject.email mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.email is not mandatory"); + assertNotNull(claim.getDisplay(), "credentialSubject.email has display"); + assertEquals(15, claim.getDisplay().size()); + for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { + assertNotNull(birthDateDisplay.getName()); + assertNotNull(birthDateDisplay.getLocale()); + } + } + { + Claim claim = jwtVcClaims.get(5); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.address_locality"); + assertEquals("address", claim.getPath().get(1), "credentialSubject.address.locality mapped correctly (parent claim in path)"); + assertEquals("locality", claim.getPath().get(2), "credentialSubject.address.locality mapped correctly (nested claim in path)"); + assertFalse(claim.isMandatory(), "credentialSubject.address.locality is not mandatory"); + assertNull(claim.getDisplay(), "credentialSubject.address.locality has no display"); + } + { + Claim claim = jwtVcClaims.get(6); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.address.street_address"); + assertEquals("address", claim.getPath().get(1), "credentialSubject.address.street_address mapped correctly (parent claim in path)"); + assertEquals("street_address", claim.getPath().get(2), "credentialSubject.address.street_address mapped correctly (nested claim in path)"); + assertFalse(claim.isMandatory(), "credentialSubject.address.street_address is not mandatory"); + assertNull(claim.getDisplay(), "credentialSubject.address.street_address has no display"); + } + { + Claim claim = jwtVcClaims.get(7); + assertEquals(CREDENTIAL_SUBJECT, claim.getPath().get(0), "Has credentialSubject.scope-name"); + assertEquals("scope-name", claim.getPath().get(1), "credentialSubject.scope-name mapped correctly"); + assertFalse(claim.isMandatory(), "credentialSubject.scope-name is not mandatory"); + assertNull(claim.getDisplay(), "credentialSubject.scope-name has no display"); + } + + assertNotNull(jwtVcConfig.getCredentialDefinition(), "The jwt_vc-credential should offer credential_definition"); + assertNull(jwtVcConfig.getVct(), "JWT_VC credentials should not have vct"); + + assertTrue(jwtVcConfig.getCryptographicBindingMethodsSupported().contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT), + "The jwt_vc-credential should contain a cryptographic binding method supported named jwk"); + assertTrue(jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256"), + "The jwt_vc-credential should contain a credential signing algorithm named RS256"); + + assertTrue(credentialIssuer.getCredentialsSupported() + .get(credentialConfigurationId) + .getProofTypesSupported() + .getSupportedProofTypes() + .get("jwt") + .getSigningAlgorithmsSupported() + .contains("RS256"), + "The jwt_vc-credential should support a proof of type jwt with signing algorithm RS256"); + + String expectedDisplay = (jwtVcConfig.getCredentialMetadata() != null && jwtVcConfig.getCredentialMetadata().getDisplay() != null) + ? jwtVcConfig.getCredentialMetadata().getDisplay().get(0).getName() + : null; + assertEquals(credentialConfigurationId, expectedDisplay, "The jwt_vc-credential should display as Test Credential"); + }); + } + + @Test + public void testRequestCredentialWithUnknownCredentialIdentifier() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier("unknown-credential-identifier"); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + + try { + issuerEndpoint.requestCredential(requestPayload); + fail("Expected BadRequestException due to unknown credential identifier"); + } catch (BadRequestException e) { + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER.getValue(), error.getError()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testRequestCredentialWithUnknownCredentialConfigurationId() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialConfigurationId("unknown-configuration-id"); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + + try { + issuerEndpoint.requestCredential(requestPayload); + fail("Expected BadRequestException due to unknown credential configuration"); + } catch (BadRequestException e) { + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION.getValue(), error.getError()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testRequestCredentialWhenNoCredentialBuilderForFormat() { + String scopeName = jwtTypeCredentialClientScope.getName(); + String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credConfigId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + + assertNotNull(authDetailsResponse, "authorization_details should be present in the response"); + assertFalse(authDetailsResponse.isEmpty(), "authorization_details should not be empty"); + + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + assertNotNull(credentialIdentifier, "credential_identifier should be present"); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + + try { + issuerEndpoint.requestCredential(requestPayload); + fail("Expected BadRequestException due to missing credential builder for format"); + } catch (BadRequestException e) { + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION.getValue(), error.getError()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testProofToProofsConversion() { + final String scopeName = jwtTypeCredentialClientScope.getName(); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(credentialConfigurationId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + // Test 1: Create a request with single proof field - should be converted to proofs array + CredentialRequest requestWithProof = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier); + + JwtProof singleProof = new JwtProof() + .setJwt("dummy-jwt") + .setProofType("jwt"); + requestWithProof.setProof(singleProof); + + String requestPayload = JsonSerialization.writeValueAsString(requestWithProof); + + try { + issuerEndpoint.requestCredential(requestPayload); + fail(); + } catch (Exception e) { + assertTrue(e.getMessage().contains("Could not validate provided proof"), "Error should be related to JWT validation, not conversion"); + } + + // Test 2: Create a request with both proof and proofs fields - should fail validation + CredentialRequest requestWithBoth = new CredentialRequest() + .setCredentialIdentifier(credentialIdentifier); + + requestWithBoth.setProof(singleProof); + + Proofs proofsArray = new Proofs(); + proofsArray.setJwt(List.of("dummy-jwt")); + requestWithBoth.setProofs(proofsArray); + + String bothFieldsPayload = JsonSerialization.writeValueAsString(requestWithBoth); + + try { + issuerEndpoint.requestCredential(bothFieldsPayload); + fail("Expected BadRequestException when both proof and proofs are provided"); + } catch (BadRequestException e) { + int statusCode = e.getResponse().getStatus(); + assertEquals(400, statusCode, "Expected HTTP 400 Bad Request"); + ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); + assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST.getValue(), error.getError()); + assertEquals("Both 'proof' and 'proofs' must not be present at the same time", error.getErrorDescription()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCredentialRequestWithOptionalClientScope() { + ClientScopeRepresentation optionalScope = createOptionalClientScope( + "optional-jwt-credential", + ISSUER_DID.toString(), + "optional-jwt-credential-config-id", + null, null, + VCFormat.JWT_VC, + null, null + ); + + optionalScope = registerOptionalClientScope(optionalScope); + ClientRepresentation testClient = testRealm.admin().clients().findByClientId(OID4VCI_CLIENT_ID).get(0); + testRealm.admin().clients().get(testClient.getId()).addOptionalClientScope(optionalScope.getId()); + + final String scopeName = optionalScope.getName(); + final String configId = optionalScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); + + CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); + OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); + authDetail.setType(OPENID_CREDENTIAL); + authDetail.setCredentialConfigurationId(configId); + authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); + + String authCode = getAuthorizationCode(oauth, client, "john", scopeName); + AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); + + String token = tokenResponse.getAccessToken(); + List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); + String credentialConfigurationId = authDetailsResponse.get(0).getCredentialConfigurationId(); + + runOnServer.run(session -> { + try { + BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + CredentialRequest credentialRequest = new CredentialRequest() + .setCredentialConfigurationId(credentialConfigurationId); + + String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); + Response credentialResponse = issuerEndpoint.requestCredential(requestPayload); + + assertEquals(HttpStatus.SC_OK, credentialResponse.getStatus(), "The credential request should succeed for Optional client scope"); + assertNotNull(credentialResponse.getEntity(), "A credential should be returned"); + + CredentialResponse credentialResponseVO = JsonSerialization.mapper + .convertValue(credentialResponse.getEntity(), CredentialResponse.class); + + assertNotNull(credentialResponseVO.getCredentials(), "Credentials array should not be null"); + assertFalse(credentialResponseVO.getCredentials().isEmpty(), "Credentials array should not be empty"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testCannotAssignOid4vciScopeAsDefaultToClient() { + ClientScopeRepresentation oid4vciScope = createOptionalClientScope( + "test-oid4vci-scope", + ISSUER_DID.toString(), + "test-oid4vci-config-id", + null, null, + VCFormat.JWT_VC, + null, null + ); + + oid4vciScope = registerOptionalClientScope(oid4vciScope); + ClientRepresentation testClient = testRealm.admin().clients().findByClientId(OID4VCI_CLIENT_ID).get(0); + ClientResource clientResource = testRealm.admin().clients().get(testClient.getId()); + + try { + clientResource.addDefaultClientScope(oid4vciScope.getId()); + fail("Expected BadRequestException when trying to assign OID4VCI scope as Default"); + } catch (BadRequestException e) { + OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); + assertEquals("OID4VCI client scopes cannot be assigned as Default scopes. Only Optional scope assignment is supported.", + error.getErrorDescription()); + } + } + + @Test + public void testCannotAssignOid4vciScopeAsDefaultToRealm() { + ClientScopeRepresentation oid4vciScope = createOptionalClientScope( + "test-oid4vci-realm-scope", + ISSUER_DID.toString(), + "test-oid4vci-realm-config-id", + null, null, + VCFormat.JWT_VC, + null, null + ); + + oid4vciScope = registerOptionalClientScope(oid4vciScope); + + try { + testRealm.admin().addDefaultDefaultClientScope(oid4vciScope.getId()); + fail("Expected BadRequestException when trying to assign OID4VCI scope as realm Default"); + } catch (BadRequestException e) { + OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); + assertEquals("OID4VCI client scopes cannot be assigned as Default scopes. Only Optional scope assignment is supported.", + error.getErrorDescription()); + } + } + + @Test + public void testCannotAssignOid4vciScopeWhenRealmDisabled() { + ClientScopeRepresentation oid4vciScope = createOptionalClientScope( + "test-oid4vci-disabled-scope", + ISSUER_DID.toString(), + "test-oid4vci-disabled-config-id", + null, null, + VCFormat.JWT_VC, + null, null + ); + + oid4vciScope = registerOptionalClientScope(oid4vciScope); + + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setVerifiableCredentialsEnabled(false); + }); + + try { + ClientRepresentation testClient = testRealm.admin().clients().findByClientId(OID4VCI_CLIENT_ID).get(0); + ClientResource clientResource = testRealm.admin().clients().get(testClient.getId()); + + try { + clientResource.addOptionalClientScope(oid4vciScope.getId()); + fail("Expected BadRequestException when OID4VCI is disabled at realm level"); + } catch (BadRequestException e) { + OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); + assertEquals("OID4VCI client scopes cannot be assigned when Verifiable Credentials is disabled for the realm", + error.getErrorDescription()); + } + } finally { + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + realm.setVerifiableCredentialsEnabled(true); + }); + } + } + + @Test + public void testCredentialOfferReplayProtection() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + // 1. Retrieving the create-credential-offer + CredentialOfferURI credentialOfferURI = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + + assertNotNull(credentialOfferURI, "Credential offer URI should not be null"); + + String nonce = credentialOfferURI.getNonce(); + assertNotNull(nonce, "Nonce should not be null"); + + // 2. First access to the credential offer URL - should succeed + CredentialsOffer credentialsOffer = oauth.oid4vc() + .credentialOfferRequest(nonce) + .bearerToken(token) + .send() + .getCredentialsOffer(); + + assertNotNull(credentialsOffer, "Credential offer should not be null"); + + String preAuthorizedCode = credentialsOffer.getPreAuthorizedCode(); + assertNotNull(preAuthorizedCode, "Pre-authorized code value should not be null"); + + // 3. Second access to the same credential offer URL - should fail with replay protection error + CredentialOfferResponse response = oauth.oid4vc() + .credentialOfferRequest(nonce) + .bearerToken(token) + .send(); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode(), "Second access to credential offer should fail with 400 Bad Request"); + assertEquals(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), response.getError(), "Error type should be INVALID_CREDENTIAL_OFFER_REQUEST"); + assertTrue(response.getErrorDescription().contains("not found") || response.getErrorDescription().contains("already consumed"), "Error description should mention that offer is not found or already consumed"); + } + + @Test + public void testCredentialOfferDifferentNoncesIndependent() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + // 1. Create first credential offer + CredentialOfferURI credentialOfferURI1 = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + + assertNotNull(credentialOfferURI1, "First credential offer URI should not be null"); + String nonce1 = credentialOfferURI1.getNonce(); + assertNotNull(nonce1, "First nonce should not be null"); + + // 2. Create second credential offer (should have different nonce) + CredentialOfferURI credentialOfferURI2 = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + + assertNotNull(credentialOfferURI2, "Second credential offer URI should not be null"); + String nonce2 = credentialOfferURI2.getNonce(); + assertNotNull(nonce2, "Second nonce should not be null"); + assertNotEquals(nonce1, nonce2, "Nonces should be different for different offers"); + + // 3. Access first offer - should succeed + CredentialsOffer credentialsOffer1 = oauth.oid4vc() + .credentialOfferRequest(nonce1) + .bearerToken(token) + .send() + .getCredentialsOffer(); + assertNotNull(credentialsOffer1, "First credential offer should not be null"); + + // 4. Access second offer - should also succeed (different nonce, independent state) + CredentialsOffer credentialsOffer2 = oauth.oid4vc() + .credentialOfferRequest(nonce2) + .bearerToken(token) + .send() + .getCredentialsOffer(); + assertNotNull(credentialsOffer2, "Second credential offer should not be null"); + + // 5. Verify that accessing first offer again fails (replay protection per-nonce) + CredentialOfferResponse response1 = oauth.oid4vc() + .credentialOfferRequest(nonce1) + .bearerToken(token) + .send(); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response1.getStatusCode(), "First offer should fail on second access (replay protection)"); + assertEquals(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), response1.getError(), "Error type should be INVALID_CREDENTIAL_OFFER_REQUEST"); + + // 6. Verify that accessing second offer again also fails (replay protection per-nonce) + CredentialOfferResponse response2 = oauth.oid4vc() + .credentialOfferRequest(nonce2) + .bearerToken(token) + .send(); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response2.getStatusCode(), "Second offer should fail on second access (replay protection)"); + assertEquals(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), response2.getError(), "Error type should be INVALID_CREDENTIAL_OFFER_REQUEST"); + } + + @Test + public void testPreAuthorizedCodeValidAfterOfferConsumed() { + String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); + final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() + .get(CredentialScopeModel.VC_CONFIGURATION_ID); + + // 1. Fetch the Offer URI + CredentialOfferURI credentialOfferURI = oauth.oid4vc() + .credentialOfferUriRequest(credentialConfigurationId) + .preAuthorized(true) + .targetUser("john") + .bearerToken(token) + .send() + .getCredentialOfferURI(); + + assertNotNull(credentialOfferURI, "Credential offer URI should not be null"); + String nonce = credentialOfferURI.getNonce(); + assertNotNull(nonce, "Nonce should not be null"); + + // 2. Fetch the Offer JSON (this removes the nonce entry for replay protection) + CredentialsOffer credentialsOffer = oauth.oid4vc() + .credentialOfferRequest(nonce) + .bearerToken(token) + .send() + .getCredentialsOffer(); + + assertNotNull(credentialsOffer, "Credential offer should not be null"); + + String preAuthorizedCode = credentialsOffer.getPreAuthorizedCode(); + assertNotNull(preAuthorizedCode, "Pre-authorized code value should not be null"); + + // 3. Immediately perform the Token Request (Pre-Authorized Code Grant) using the valid code + CredentialIssuer credentialIssuer = oauth.oid4vc() + .issuerMetadataRequest() + .endpoint(credentialsOffer.getIssuerMetadataUrl()) + .send() + .getMetadata(); + + OIDCConfigurationRepresentation openidConfig = oauth.wellknownRequest() + .url(credentialIssuer.getAuthorizationServers().get(0)) + .send() + .getOidcConfiguration(); + + assertNotNull(openidConfig.getTokenEndpoint(), "Token endpoint should be present"); + + AccessTokenResponse accessTokenResponse = oauth.oid4vc() + .preAuthorizedCodeGrantRequest(preAuthorizedCode) + .send(); + + assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode(), "Token request should succeed even after nonce is removed for replay protection"); + assertNotNull(accessTokenResponse.getAccessToken(), "Access token should be present"); + assertFalse(accessTokenResponse.getAccessToken().isEmpty(), "Access token should not be empty"); + } + + public String getCNonce() { + UriBuilder builder = UriBuilder.fromUri(keycloakUrls.getBase()); + URI oid4vcUri = RealmsResource.protocolUrl(builder) + .build(testRealm.getName(), OID4VCLoginProtocolFactory.PROTOCOL_ID); + String nonceUrl = String.format("%s/%s", oid4vcUri.toString(), OID4VCIssuerEndpoint.NONCE_PATH); + + String nonceResponseString; + + // request cNonce + try (Client restClient = Keycloak.getClientProvider().newRestEasyClient(null, null, true)) { + WebTarget nonceTarget = restClient.target(nonceUrl); + Invocation.Builder nonceInvocationBuilder = nonceTarget.request() + .header(HttpHeaders.AUTHORIZATION, null) + .header(HttpHeaders.COOKIE, null); + + try (Response response = nonceInvocationBuilder.post(null)) { + assertEquals(HttpStatus.SC_OK, response.getStatus()); + assertTrue(response.getMediaType().toString().startsWith(MediaType.APPLICATION_JSON_TYPE.toString())); + + nonceResponseString = parseResponse(response); + assertNotNull(nonceResponseString); + assertEquals("no-store", response.getHeaderString(HttpHeaders.CACHE_CONTROL)); + } + } + + NonceResponse nonceResponse; + try { + nonceResponse = JsonSerialization.readValue(nonceResponseString, NonceResponse.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return nonceResponse.getNonce(); + } + + public static String parseResponse(Response response) { + try { + return response.readEntity(String.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static String generateJwtProof(String aud, String nonce) { + KeyWrapper keyWrapper = getECKey(null); + keyWrapper.setKid(null); // erase the autogenerated one + + // JWK public key + JWK jwk = JWKBuilder.create().ec(keyWrapper.getPublicKey()); + + return generateUnsignedJwtProof(jwk, aud, nonce) + .sign(new ECDSASignatureSignerContext(keyWrapper)); + } + + public static JWSBuilder.EncodingBuilder generateUnsignedJwtProof(JWK jwk, String aud, String nonce) { + AccessToken token = new AccessToken(); + token.addAudience(aud); + token.setNonce(nonce); + token.issuedNow(); + + return new JWSBuilder() + .type(JwtProofValidator.PROOF_JWT_TYP) + .jwk(jwk) + .jsonContent(token); + } + + public static KeyWrapper getECKey(String keyId) { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", BouncyIntegration.PROVIDER); + kpg.initialize(256); + var keyPair = kpg.generateKeyPair(); + + KeyWrapper kw = new KeyWrapper(); + kw.setPrivateKey(keyPair.getPrivate()); + kw.setPublicKey(keyPair.getPublic()); + kw.setUse(KeyUse.SIG); + + if (keyId != null) { + kw.setKid(keyId); + } else { + kw.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + } + + kw.setType("EC"); + kw.setAlgorithm("ES256"); + + return kw; + } catch (NoSuchAlgorithmException | NoSuchProviderException e) { + throw new RuntimeException(e); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java deleted file mode 100644 index 73199eb7be3..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ /dev/null @@ -1,1361 +0,0 @@ -/* - * 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.testsuite.oid4vc.issuance.signing; - -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.function.BiFunction; -import java.util.function.Consumer; - -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -import org.keycloak.TokenVerifier; -import org.keycloak.VCFormat; -import org.keycloak.admin.client.resource.ClientResource; -import org.keycloak.common.VerificationException; -import org.keycloak.common.util.Time; -import org.keycloak.models.RealmModel; -import org.keycloak.models.oid4vci.CredentialScopeModel; -import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; -import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; -import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState; -import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage; -import org.keycloak.protocol.oid4vc.model.Claim; -import org.keycloak.protocol.oid4vc.model.ClaimDisplay; -import org.keycloak.protocol.oid4vc.model.Claims; -import org.keycloak.protocol.oid4vc.model.CredentialIssuer; -import org.keycloak.protocol.oid4vc.model.CredentialOfferURI; -import org.keycloak.protocol.oid4vc.model.CredentialRequest; -import org.keycloak.protocol.oid4vc.model.CredentialResponse; -import org.keycloak.protocol.oid4vc.model.CredentialsOffer; -import org.keycloak.protocol.oid4vc.model.ErrorResponse; -import org.keycloak.protocol.oid4vc.model.ErrorType; -import org.keycloak.protocol.oid4vc.model.JwtProof; -import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; -import org.keycloak.protocol.oid4vc.model.PreAuthorizedCodeGrant; -import org.keycloak.protocol.oid4vc.model.Proofs; -import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; -import org.keycloak.protocol.oid4vc.model.VerifiableCredential; -import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; -import org.keycloak.representations.JsonWebToken; -import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.OAuth2ErrorRepresentation; -import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator; -import org.keycloak.testsuite.util.oauth.AccessTokenResponse; -import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; -import org.keycloak.util.JsonSerialization; - -import org.apache.http.HttpStatus; -import org.junit.Assert; -import org.junit.Test; - -import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT; -import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -/** - * Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest - */ -public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { - - // ----- getCredentialOfferUri - - @Test - public void testGetCredentialOfferUriUnsupportedCredential() { - String token = getBearerToken(oauth); - testingClient.server(TEST_REALM_NAME).run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.createCredentialOffer("inexistent-id") - ); - assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), - exception.getResponse().getStatus()); - })); - } - - @Test - public void testGetCredentialOfferUriUnauthorized() { - testingClient.server(TEST_REALM_NAME).run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.createCredentialOffer("test-credential", true, "john") - ); - assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), - exception.getResponse().getStatus()); - })); - } - - @Test - public void testGetCredentialOfferUriInvalidToken() { - testingClient.server(TEST_REALM_NAME).run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString("invalid-token"); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - oid4VCIssuerEndpoint.createCredentialOffer("test-credential", true, "john") - ); - assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), - exception.getResponse().getStatus()); - })); - } - - @Test - public void testGetCredentialOfferURI() { - final String scopeName = jwtTypeCredentialClientScope.getName(); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - String token = getBearerToken(oauth, client, scopeName); - - testingClient.server(TEST_REALM_NAME).run((session) -> { - try { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - Response response = oid4VCIssuerEndpoint.createCredentialOffer(credentialConfigurationId); - - assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus()); - CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), - CredentialOfferURI.class); - assertNotNull("A nonce should be included.", credentialOfferURI.getNonce()); - assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - } - - // ----- getCredentialOffer - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferUnauthorized() throws Throwable { - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response response = issuerEndpoint.getCredentialOffer("nonce"); - assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType()); - }); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutNonce() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(null); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithoutAPreparedOffer() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer("unpreparedNonce"); - })); - }); - } - - @Test(expected = BadRequestException.class) - public void testGetCredentialOfferWithABrokenNote() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - String nonce = prepareSessionCode(session, authenticator, "invalidNote").key(); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(nonce); - })); - }); - } - - @Test - public void testGetCredentialOffer() { - String token = getBearerToken(oauth); - testingClient - .server(TEST_REALM_NAME) - .run((session) -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - CredentialsOffer credOffer = new CredentialsOffer() - .setCredentialIssuer("the-issuer") - .addGrant(new PreAuthorizedCodeGrant().setPreAuthorizedCode("the-code")) - .setCredentialConfigurationIds(List.of("credential-configuration-id")); - - CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class); - CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() + 60, null); - offerStorage.putOfferState(offerState); - - // The cache transactions need to be committed explicitly in the test. Without that, the OAuth2Code will only be committed to - // the cache after .run((session)-> ...) - session.getTransactionManager().commit(); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(offerState.getNonce()); - assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); - Object credentialOfferEntity = credentialOfferResponse.getEntity(); - assertNotNull("An actual offer should be in the response.", credentialOfferEntity); - - CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertEquals("The offer should be the one prepared with for the session.", credOffer, retrievedCredentialsOffer); - }); - } - -// ----- requestCredential - - @Test - public void testRequestCredentialUnauthorized() { - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(null); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier("test-credential"); - - String requestPayload; - requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - issuerEndpoint.requestCredential(requestPayload) - ); - assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), - exception.getResponse().getStatus()); - }); - } - - @Test - public void testRequestCredentialInvalidToken() { - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString("token"); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier("test-credential"); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () -> - issuerEndpoint.requestCredential(requestPayload) - ); - assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(), - exception.getResponse().getStatus()); - }); - } - - @Test - public void testRequestCredentialNoMatchingCredentialBuilder() throws Throwable { - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - final String scopeName = jwtTypeCredentialClientScope.getName(); - - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(credentialConfigurationId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - - try { - withCausePropagation(() -> { - testingClient.server(TEST_REALM_NAME).run((session -> { - BearerTokenAuthenticator authenticator = - new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - - // Prepare the issue endpoint with no credential builders. - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); - - CredentialRequest credentialRequest = - new CredentialRequest().setCredentialIdentifier(credentialIdentifier); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - issuerEndpoint.requestCredential(requestPayload); - })); - }); - Assert.fail("Should have thrown an exception"); - } catch (Exception e) { - Assert.assertTrue(e instanceof BadRequestException); - Assert.assertEquals("No credential builder found for format jwt_vc_json", e.getMessage()); - } - } - - @Test(expected = BadRequestException.class) - public void testRequestCredentialUnsupportedCredential() throws Throwable { - String token = getBearerToken(oauth); - withCausePropagation(() -> { - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier("no-such-credential"); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - issuerEndpoint.requestCredential(requestPayload); - }); - }); - } - - @Test - public void testRequestCredential() { - String scopeName = jwtTypeCredentialClientScope.getName(); - String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(credConfigId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty()); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - assertNotNull("credential_identifier should be present", credentialIdentifier); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(credentialIdentifier); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - Response credentialResponse = issuerEndpoint.requestCredential(requestPayload); - assertEquals("The credential request should be answered successfully.", - HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be responded.", credentialResponse.getEntity()); - CredentialResponse credentialResponseVO = JsonSerialization.mapper - .convertValue(credentialResponse.getEntity(), CredentialResponse.class); - JsonWebToken jsonWebToken; - try { - jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(), - JsonWebToken.class).getToken(); - } catch (VerificationException e) { - Assert.fail("Failed to verify JWT: " + e.getMessage()); - return; - } - assertNotNull("A valid credential string should have been responded", jsonWebToken); - assertNotNull("The credentials should be included at the vc-claim.", - jsonWebToken.getOtherClaims().get("vc")); - VerifiableCredential credential = JsonSerialization.mapper.convertValue( - jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertTrue("The static claim should be set.", - credential.getCredentialSubject().getClaims().containsKey("scope-name")); - assertEquals("The static claim should be set.", - scopeName, credential.getCredentialSubject().getClaims().get("scope-name")); - assertFalse("Only mappers supported for the requested type should have been evaluated.", - credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); - }); - } - - @Test - public void testRequestCredentialWithNeitherIdSet() { - final String scopeName = minimalJwtTypeCredentialClientScope.getName(); - String token = getBearerToken(oauth, client, scopeName); - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - CredentialRequest credentialRequest = new CredentialRequest(); - try { - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - issuerEndpoint.requestCredential(requestPayload); - Assert.fail("Expected BadRequestException due to missing credential identifier or configuration id"); - } catch (BadRequestException e) { - ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); - assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST.getValue(), error.getError()); - } - }); - } - - // Tests the complete flow from - // 1. Retrieving the create-credential-offer - // 2. Using the uri to get the actual credential offer - // 3. Get the issuer metadata - // 4. Get the openid-configuration - // 5. Get an access token for the pre-authorized code - // 6. Get the credential - @Test - public void testCredentialIssuance() throws Exception { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - - // 1. Retrieving the credential-offer-uri - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - CredentialOfferURI credOfferUri = oauth.oid4vc() - .credentialOfferUriRequest(credentialConfigurationId) - .preAuthorized(true) - .targetUser("john") - .bearerToken(token) - .send() - .getCredentialOfferURI(); - - assertNotNull("A valid offer uri should be returned", credOfferUri); - - // 2. Using the uri to get the actual credential offer - CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().doCredentialOfferRequest(credOfferUri); - CredentialsOffer credentialsOffer = credentialOfferResponse.getCredentialsOffer(); - - assertNotNull("A valid offer should be returned", credentialsOffer); - - // 3. Get the issuer metadata - CredentialIssuer credentialIssuer = oauth.oid4vc() - .issuerMetadataRequest() - .endpoint(credentialsOffer.getIssuerMetadataUrl()) - .send() - .getMetadata(); - - assertNotNull("Issuer metadata should be returned", credentialIssuer); - assertEquals("We only expect one authorization server.", 1, credentialIssuer.getAuthorizationServers().size()); - - // 4. Get the openid-configuration - OIDCConfigurationRepresentation openidConfig = oauth - .wellknownRequest() - .url(credentialIssuer.getAuthorizationServers().get(0)) - .send() - .getOidcConfiguration(); - - assertNotNull("A token endpoint should be included.", openidConfig.getTokenEndpoint()); - assertTrue("The pre-authorized code should be supported.", openidConfig.getGrantTypesSupported().contains(PreAuthorizedCodeGrant.PRE_AUTH_GRANT_TYPE)); - - // 5. Get an access token for the pre-authorized code - AccessTokenResponse accessTokenResponse = oauth.oid4vc() - .preAuthorizedCodeGrantRequest(credentialsOffer.getPreAuthorizedCode()) - .endpoint(openidConfig.getTokenEndpoint()) - .send(); - - assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode()); - String theToken = accessTokenResponse.getAccessToken(); - assertNotNull("Access token should be present", theToken); - - // Extract credential_identifier from authorization_details in token response - List authDetailsResponse = accessTokenResponse.getOID4VCAuthorizationDetails(); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty()); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - assertNotNull("Credential identifier should be present", credentialIdentifier); - - // 6. Get the credential using credential_identifier (required when authorization_details are present) - credentialsOffer.getCredentialConfigurationIds().stream() - .map(credConfigId -> credentialIssuer.getCredentialsSupported().get(credConfigId)) - .forEach(supportedCredential -> { - try { - requestCredentialWithIdentifier(theToken, - credentialIssuer.getCredentialEndpoint(), - credentialIdentifier, - new CredentialResponseHandler(), - jwtTypeCredentialClientScope); - } catch (IOException e) { - fail("Was not able to get the credential."); - } catch (VerificationException e) { - throw new RuntimeException(e); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { - String scopeName = jwtTypeCredentialClientScope.getName(); - String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - - AccessTokenResponse accessTokenResponse = oauth.accessTokenRequest(authCode).send(); - assertTrue("Access token request should succeed", accessTokenResponse.isSuccess()); - assertNotNull("Access token should be present", accessTokenResponse.getAccessToken()); - - List authDetailsResponse = accessTokenResponse.getOID4VCAuthorizationDetails(); - assertNotNull("authorization_details should be present in the token response", authDetailsResponse); - assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty()); - OID4VCAuthorizationDetail firstAuthorizationDetail = authDetailsResponse.get(0); - assertEquals("credential_configuration_id should match requested scope", - credentialConfigurationId, firstAuthorizationDetail.getCredentialConfigurationId()); - assertNotNull("credential_identifiers should be present", firstAuthorizationDetail.getCredentialIdentifiers()); - assertFalse("credential_identifiers should not be empty", firstAuthorizationDetail.getCredentialIdentifiers().isEmpty()); - String credentialIdentifier = firstAuthorizationDetail.getCredentialIdentifiers().get(0); - - requestCredentialWithIdentifier( - accessTokenResponse.getAccessToken(), - credentialIssuer.getCredentialEndpoint(), - credentialIdentifier, - new CredentialResponseHandler(), - jwtTypeCredentialClientScope - ); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, (testClientId, testScope) -> - getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),// set registered different scope - m -> { - String accessToken = (String) m.get("accessToken"); - WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - @Test - public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception { - testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, - (testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)),// no scope - m -> { - String accessToken = (String) m.get("accessToken"); - WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - } - }); - } - - /** - * When the token contains authorization_details, the credential_identifier from the request must match that in - * the authorization_details from the AccessTokenResponse and in the AccessToken JWT. - */ - @Test - public void testCredentialIssuanceWithScopeUnmatched() { - BiFunction getAccessToken = (testClientId, testScope) -> - getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - - Consumer> sendCredentialRequest = m -> { - String accessToken = (String) m.get("accessToken"); - WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); - CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); - - try (Response response = credentialTarget.request() - .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) - .post(Entity.json(credentialRequest))) { - assertEquals(400, response.getStatus()); - String errorMessage = response.readEntity(String.class); - - assertTrue(errorMessage.contains("unknown_credential_identifier")); - assertTrue(errorMessage.contains("credential_identifier must match one from the authorization_details in the access token")); - } - }; - - testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest, - (cid) -> new CredentialRequest().setCredentialIdentifier("sd-jwt-credential")); - } - - /** - * This is testing the multiple credential issuance flow in a single call with proofs - */ - @Test - public void testRequestMultipleCredentialsWithProofs() { - final String scopeName = jwtTypeCredentialClientScope.getName(); - String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); - - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(credConfigId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - - String cNonce = getCNonce(); - - testingClient.server(TEST_REALM_NAME).run(session -> { - try { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()); - - String jwtProof1 = generateJwtProof(issuer, cNonce); - String jwtProof2 = generateJwtProof(issuer, cNonce); - Proofs proofs = new Proofs().setJwt(Arrays.asList(jwtProof1, jwtProof2)); - - - CredentialRequest request = new CredentialRequest() - .setCredentialIdentifier(credentialIdentifier) - .setProofs(proofs); - - OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator); - - String requestPayload = JsonSerialization.writeValueAsString(request); - - Response response = endpoint.requestCredential(requestPayload); - assertEquals("Response status should be OK", Response.Status.OK.getStatusCode(), response.getStatus()); - - CredentialResponse credentialResponse = JsonSerialization.mapper - .convertValue(response.getEntity(), CredentialResponse.class); - assertNotNull("Credential response should not be null", credentialResponse); - assertNotNull("Credentials array should not be null", credentialResponse.getCredentials()); - assertEquals("Should return 2 credentials due to two proofs", 2, credentialResponse.getCredentials().size()); - - // Validate each credential - for (CredentialResponse.Credential credential : credentialResponse.getCredentials()) { - assertNotNull("Credential should not be null", credential.getCredential()); - JsonWebToken jsonWebToken; - try { - jsonWebToken = TokenVerifier.create((String) credential.getCredential(), JsonWebToken.class).getToken(); - } catch (VerificationException e) { - Assert.fail("Failed to verify JWT: " + e.getMessage()); - return; - } - assertNotNull("A valid credential string should be returned", jsonWebToken); - assertNotNull("The credentials should include the vc claim", jsonWebToken.getOtherClaims().get("vc")); - - VerifiableCredential vc = JsonSerialization.mapper.convertValue( - jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class); - assertTrue("The scope-name claim should be set", - vc.getCredentialSubject().getClaims().containsKey("scope-name")); - assertEquals("The scope-name claim should match the scope", - scopeName, vc.getCredentialSubject().getClaims().get("scope-name")); - assertTrue("The given_name claim should be set", - vc.getCredentialSubject().getClaims().containsKey("given_name")); - assertEquals("The given_name claim should be John", - "John", vc.getCredentialSubject().getClaims().get("given_name")); - assertTrue("The email claim should be set", - vc.getCredentialSubject().getClaims().containsKey("email")); - assertEquals("The email claim should be john@email.cz", - "john@email.cz", vc.getCredentialSubject().getClaims().get("email")); - assertFalse("Only supported mappers should be evaluated", - vc.getCredentialSubject().getClaims().containsKey("AnotherCredentialType")); - } - } catch (Exception e) { - throw new RuntimeException("Test failed due to: " + e.getMessage(), e); - } - }); - } - - /** - * This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here. - */ - @Test - public void testGetJwtVcConfigFromMetadata() { - final String scopeName = jwtTypeCredentialClientScope.getName(); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - final String verifiableCredentialType = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VCT); - String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; - String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential"; - String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH; - final String expectedAuthorizationServer = expectedIssuer; - testingClient - .server(TEST_REALM_NAME) - .run((session -> { - OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); - CredentialIssuer credentialIssuer = oid4VCIssuerWellKnownProvider.getIssuerMetadata(); - assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer()); - assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint()); - assertEquals("The correct nonce endpoint should be included.", - expectedNonceEndpoint, - credentialIssuer.getNonceEndpoint()); - assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size()); - assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0)); - - assertTrue("The jwt_vc-credential should be supported.", - credentialIssuer.getCredentialsSupported() - .containsKey(credentialConfigurationId)); - - SupportedCredentialConfiguration jwtVcConfig = - credentialIssuer.getCredentialsSupported().get(credentialConfigurationId); - assertEquals("The jwt_vc-credential should offer type test-credential", - scopeName, - jwtVcConfig.getScope()); - assertEquals("The jwt_vc-credential should be offered in the jwt_vc format.", - VCFormat.JWT_VC, - jwtVcConfig.getFormat()); - - Claims jwtVcClaims = jwtVcConfig.getCredentialMetadata() != null ? jwtVcConfig.getCredentialMetadata().getClaims() : null; - assertNotNull("The jwt_vc-credential can optionally provide a claims claim.", - jwtVcClaims); - - assertEquals(8, jwtVcClaims.size()); - { - Claim claim = jwtVcClaims.get(0); - assertEquals("Has credentialSubject.id", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.id mapped correctly","id", claim.getPath().get(1)); - assertFalse("credentialSubject.id is not mandatory", claim.isMandatory()); - assertNull("credentialSubject.id has no display", claim.getDisplay()); - } - { - Claim claim = jwtVcClaims.get(1); - assertEquals("Has credentialSubject.given_name", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.given_name mapped correctly","given_name", claim.getPath().get(1)); - assertFalse("credentialSubject.given_name is not mandatory", claim.isMandatory()); - assertNotNull("credentialSubject.given_name has display", claim.getDisplay()); - assertEquals(15, claim.getDisplay().size()); - for (ClaimDisplay givenNameDisplay : claim.getDisplay()) { - assertNotNull(givenNameDisplay.getName()); - assertNotNull(givenNameDisplay.getLocale()); - } - } - { - Claim claim = jwtVcClaims.get(2); - assertEquals("Has credentialSubject.family_name", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.family_name mapped correctly","family_name", claim.getPath().get(1)); - assertFalse("credentialSubject.family_name is not mandatory", claim.isMandatory()); - assertNotNull("credentialSubject.family_name has display", claim.getDisplay()); - assertEquals(15, claim.getDisplay().size()); - for (ClaimDisplay familyNameDisplay : claim.getDisplay()) { - assertNotNull(familyNameDisplay.getName()); - assertNotNull(familyNameDisplay.getLocale()); - } - } - { - Claim claim = jwtVcClaims.get(3); - assertEquals("Has credentialSubject.birthdate", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.birthdate mapped correctly","birthdate", claim.getPath().get(1)); - assertFalse("credentialSubject.birthdate is not mandatory", claim.isMandatory()); - assertNotNull("credentialSubject.birthdate has display", claim.getDisplay()); - assertEquals(15, claim.getDisplay().size()); - for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { - assertNotNull(birthDateDisplay.getName()); - assertNotNull(birthDateDisplay.getLocale()); - } - } - { - Claim claim = jwtVcClaims.get(4); - assertEquals("Has credentialSubject.email", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.email mapped correctly","email", claim.getPath().get(1)); - assertFalse("credentialSubject.email is not mandatory", claim.isMandatory()); - assertNotNull("credentialSubject.email has display", claim.getDisplay()); - assertEquals(15, claim.getDisplay().size()); - for (ClaimDisplay birthDateDisplay : claim.getDisplay()) { - assertNotNull(birthDateDisplay.getName()); - assertNotNull(birthDateDisplay.getLocale()); - } - } - { - Claim claim = jwtVcClaims.get(5); - assertEquals("Has credentialSubject.address_locality", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals( - "credentialSubject.address.locality mapped correctly (parent claim in path)", - "address", - claim.getPath().get(1) - ); - assertEquals( - "credentialSubject.address.locality mapped correctly (nested claim in path)", - "locality", - claim.getPath().get(2) - ); - assertFalse("credentialSubject.address.locality is not mandatory", claim.isMandatory()); - assertNull("credentialSubject.address.locality has no display", claim.getDisplay()); - } - { - Claim claim = jwtVcClaims.get(6); - assertEquals("Has credentialSubject.address.street_address", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals( - "credentialSubject.address.street_address mapped correctly (parent claim in path)", - "address", - claim.getPath().get(1) - ); - assertEquals( - "credentialSubject.address.street_address mapped correctly (nested claim in path)", - "street_address", - claim.getPath().get(2) - ); - assertFalse("credentialSubject.address.street_address is not mandatory", claim.isMandatory()); - assertNull("credentialSubject.address.street_address has no display", claim.getDisplay()); - } - { - Claim claim = jwtVcClaims.get(7); - assertEquals("Has credentialSubject.scope-name", CREDENTIAL_SUBJECT, claim.getPath().get(0)); - assertEquals("credentialSubject.scope-name mapped correctly","scope-name", claim.getPath().get(1)); - assertFalse("credentialSubject.scope-name is not mandatory", claim.isMandatory()); - assertNull("credentialSubject.scope-name has no display", claim.getDisplay()); - } - - assertNotNull("The jwt_vc-credential should offer credential_definition", - jwtVcConfig.getCredentialDefinition()); - assertNull("JWT_VC credentials should not have vct", jwtVcConfig.getVct()); - - // We are offering key binding only for identity credential - assertTrue("The jwt_vc-credential should contain a cryptographic binding method supported named jwk", - jwtVcConfig.getCryptographicBindingMethodsSupported() - .contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT)); - assertTrue("The jwt_vc-credential should contain a credential signing algorithm named RS256", - jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256")); - assertTrue("The jwt_vc-credential should support a proof of type jwt with signing algorithm RS256", - credentialIssuer.getCredentialsSupported() - .get(credentialConfigurationId) - .getProofTypesSupported() - .getSupportedProofTypes() - .get("jwt") - .getSigningAlgorithmsSupported() - .contains("RS256")); - assertEquals("The jwt_vc-credential should display as Test Credential", - credentialConfigurationId, - jwtVcConfig.getCredentialMetadata() != null && jwtVcConfig.getCredentialMetadata().getDisplay() != null ? - jwtVcConfig.getCredentialMetadata().getDisplay().get(0).getName() : null); - })); - } - - - /** - * Test that unknown_credential_identifier error is returned when credential_identifier is not recognized - */ - @Test - public void testRequestCredentialWithUnknownCredentialIdentifier() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier("unknown-credential-identifier"); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - try { - issuerEndpoint.requestCredential(requestPayload); - Assert.fail("Expected BadRequestException due to unknown credential identifier"); - } catch (BadRequestException e) { - ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); - assertEquals(ErrorType.UNKNOWN_CREDENTIAL_IDENTIFIER.getValue(), error.getError()); - } - }); - } - - /** - * Test that unknown_credential_configuration error is returned when the requested credential_configuration_id does not exist - */ - @Test - public void testRequestCredentialWithUnknownCredentialConfigurationId() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - // Create a credential request with a non-existent configuration ID - // This will test the invalid_credential_request error when no authorization_details present - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialConfigurationId("unknown-configuration-id"); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - try { - issuerEndpoint.requestCredential(requestPayload); - Assert.fail("Expected BadRequestException due to unknown credential configuration"); - } catch (BadRequestException e) { - ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); - assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION.getValue(), error.getError()); - } - }); - } - - /** - * Test that unknown_credential_configuration error is returned when a credential configuration exists - * but there is no credential builder registered for its format. - */ - @Test - public void testRequestCredentialWhenNoCredentialBuilderForFormat() { - String scopeName = jwtTypeCredentialClientScope.getName(); - String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(credConfigId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - assertNotNull("authorization_details should be present in the response", authDetailsResponse); - assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty()); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - assertNotNull("credential_identifier should be present", credentialIdentifier); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - // Prepare endpoint with no credential builders to simulate missing builder for the configured format - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of()); - - // Use the credential identifier from the token - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialIdentifier(credentialIdentifier); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - - try { - issuerEndpoint.requestCredential(requestPayload); - Assert.fail("Expected BadRequestException due to missing credential builder for format"); - } catch (BadRequestException e) { - ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); - assertEquals(ErrorType.UNKNOWN_CREDENTIAL_CONFIGURATION.getValue(), error.getError()); - } - }); - } - - /** - * Test that verifies the conversion from 'proof' (singular) to 'proofs' (array) works correctly. - * This test ensures backward compatibility with clients that send 'proof' instead of 'proofs'. - */ - @Test - public void testProofToProofsConversion() throws Exception { - final String scopeName = jwtTypeCredentialClientScope.getName(); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(credentialConfigurationId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - // Test 1: Create a request with single proof field - should be converted to proofs array - CredentialRequest requestWithProof = new CredentialRequest() - .setCredentialIdentifier(credentialIdentifier); - - // Create a single proof object - JwtProof singleProof = new JwtProof() - .setJwt("dummy-jwt") - .setProofType("jwt"); - requestWithProof.setProof(singleProof); - - String requestPayload = JsonSerialization.writeValueAsString(requestWithProof); - - try { - // This should work because the conversion happens in validateRequestEncryption - issuerEndpoint.requestCredential(requestPayload); - Assert.fail(); - } catch (Exception e) { - // We expect JWT validation to fail, but the conversion should have worked - assertTrue("Error should be related to JWT validation, not conversion", - e.getMessage().contains("Could not validate provided proof")); - } - - // Test 2: Create a request with both proof and proofs fields - should fail validation - CredentialRequest requestWithBoth = new CredentialRequest() - .setCredentialIdentifier(credentialIdentifier); - - requestWithBoth.setProof(singleProof); - - Proofs proofsArray = new Proofs(); - proofsArray.setJwt(List.of("dummy-jwt")); - requestWithBoth.setProofs(proofsArray); - - String bothFieldsPayload = JsonSerialization.writeValueAsString(requestWithBoth); - - try { - issuerEndpoint.requestCredential(bothFieldsPayload); - Assert.fail("Expected BadRequestException when both proof and proofs are provided"); - } catch (BadRequestException e) { - int statusCode = e.getResponse().getStatus(); - assertEquals("Expected HTTP 400 Bad Request", 400, statusCode); - ErrorResponse error = (ErrorResponse) e.getResponse().getEntity(); - assertEquals(ErrorType.INVALID_CREDENTIAL_REQUEST.getValue(), error.getError()); - assertEquals("Both 'proof' and 'proofs' must not be present at the same time", - error.getErrorDescription()); - } - }); - } - - /** - * Test that credential requests work when the client scope is assigned as Optional. - */ - @Test - public void testCredentialRequestWithOptionalClientScope() { - ClientScopeRepresentation optionalScope = createOptionalClientScope( - "optional-jwt-credential", - TEST_DID.toString(), - "optional-jwt-credential-config-id", - null, null, - VCFormat.JWT_VC, - null, null); - - optionalScope = registerOptionalClientScope(optionalScope); - ClientRepresentation testClient = testRealm().clients().findByClientId(clientId).get(0); - testRealm().clients().get(testClient.getId()).addOptionalClientScope(optionalScope.getId()); - - // Extract serializable data before lambda - final String scopeName = optionalScope.getName(); - final String configId = optionalScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); - - CredentialIssuer credentialIssuer = getCredentialIssuerMetadata(); - OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail(); - authDetail.setType(OPENID_CREDENTIAL); - authDetail.setCredentialConfigurationId(configId); - authDetail.setLocations(List.of(credentialIssuer.getCredentialIssuer())); - - String authCode = getAuthorizationCode(oauth, client, "john", scopeName); - org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = getBearerToken(oauth, authCode, authDetail); - String token = tokenResponse.getAccessToken(); - List authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails(); - String credentialConfigurationId = authDetailsResponse.get(0).getCredentialConfigurationId(); - - testingClient.server(TEST_REALM_NAME).run(session -> { - BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session); - authenticator.setTokenString(token); - OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - - CredentialRequest credentialRequest = new CredentialRequest() - .setCredentialConfigurationId(credentialConfigurationId); - - String requestPayload = JsonSerialization.writeValueAsString(credentialRequest); - Response credentialResponse = issuerEndpoint.requestCredential(requestPayload); - - assertEquals("The credential request should succeed for Optional client scope", - HttpStatus.SC_OK, credentialResponse.getStatus()); - assertNotNull("A credential should be returned", credentialResponse.getEntity()); - - CredentialResponse credentialResponseVO = JsonSerialization.mapper - .convertValue(credentialResponse.getEntity(), CredentialResponse.class); - - assertNotNull("Credentials array should not be null", credentialResponseVO.getCredentials()); - assertFalse("Credentials array should not be empty", credentialResponseVO.getCredentials().isEmpty()); - }); - } - - /** - * Test that OID4VCI client scopes cannot be assigned as Default to a client. - */ - @Test - public void testCannotAssignOid4vciScopeAsDefaultToClient() { - ClientScopeRepresentation oid4vciScope = createOptionalClientScope( - "test-oid4vci-scope", - TEST_DID.toString(), - "test-oid4vci-config-id", - null, null, - VCFormat.JWT_VC, - null, null); - - oid4vciScope = registerOptionalClientScope(oid4vciScope); - ClientRepresentation testClient = testRealm().clients().findByClientId(clientId).get(0); - ClientResource clientResource = testRealm().clients().get(testClient.getId()); - - try { - clientResource.addDefaultClientScope(oid4vciScope.getId()); - Assert.fail("Expected BadRequestException when trying to assign OID4VCI scope as Default"); - } catch (BadRequestException e) { - OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); - assertEquals("OID4VCI client scopes cannot be assigned as Default scopes. Only Optional scope assignment is supported.", - error.getErrorDescription()); - } - } - - @Test - public void testCannotAssignOid4vciScopeAsDefaultToRealm() { - ClientScopeRepresentation oid4vciScope = createOptionalClientScope( - "test-oid4vci-realm-scope", - TEST_DID.toString(), - "test-oid4vci-realm-config-id", - null, null, - VCFormat.JWT_VC, - null, null); - oid4vciScope = registerOptionalClientScope(oid4vciScope); - try { - testRealm().addDefaultDefaultClientScope(oid4vciScope.getId()); - Assert.fail("Expected BadRequestException when trying to assign OID4VCI scope as realm Default"); - } catch (BadRequestException e) { - OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); - assertEquals("OID4VCI client scopes cannot be assigned as Default scopes. Only Optional scope assignment is supported.", - error.getErrorDescription()); - } - } - - /** - * Test that OID4VCI client scopes cannot be assigned even as Optional when OID4VCI is disabled at realm level. - */ - @Test - public void testCannotAssignOid4vciScopeWhenRealmDisabled() { - ClientScopeRepresentation oid4vciScope = createOptionalClientScope( - "test-oid4vci-disabled-scope", - TEST_DID.toString(), - "test-oid4vci-disabled-config-id", - null, null, - VCFormat.JWT_VC, - null, null); - oid4vciScope = registerOptionalClientScope(oid4vciScope); - - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setVerifiableCredentialsEnabled(false); - }); - - try { - ClientRepresentation testClient = testRealm().clients().findByClientId(clientId).get(0); - ClientResource clientResource = testRealm().clients().get(testClient.getId()); - - try { - clientResource.addOptionalClientScope(oid4vciScope.getId()); - Assert.fail("Expected BadRequestException when OID4VCI is disabled at realm level"); - } catch (BadRequestException e) { - OAuth2ErrorRepresentation error = e.getResponse().readEntity(OAuth2ErrorRepresentation.class); - assertEquals("OID4VCI client scopes cannot be assigned when Verifiable Credentials is disabled for the realm", - error.getErrorDescription()); - } - } finally { - testingClient.server(TEST_REALM_NAME).run(session -> { - RealmModel realm = session.getContext().getRealm(); - realm.setVerifiableCredentialsEnabled(true); - }); - } - } - - /** - * Test that credential offer by reference can only be accessed once (replay protection). - * This ensures that the credential offer URL with a nonce can only be triggered once, - * preventing multiple retrievals of the same pre-authorized code. - */ - @Test - public void testCredentialOfferReplayProtection() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - - // 1. Retrieving the create-credential-offer - CredentialOfferURI credentialOfferURI = oauth.oid4vc() - .credentialOfferUriRequest(credentialConfigurationId) - .preAuthorized(true) - .targetUser("john") - .bearerToken(token) - .send() - .getCredentialOfferURI(); - assertNotNull("Credential offer URI should not be null", credentialOfferURI); - - String nonce = credentialOfferURI.getNonce(); - assertNotNull("Nonce should not be null", nonce); - - // 2. First access to the credential offer URL - should succeed - CredentialsOffer credentialsOffer = oauth.oid4vc() - .credentialOfferRequest(nonce) - .bearerToken(token) - .send() - .getCredentialsOffer(); - assertNotNull("Credential offer should not be null", credentialsOffer); - String preAuthorizedCode = credentialsOffer.getPreAuthorizedCode(); - assertNotNull("Pre-authorized code value should not be null", preAuthorizedCode); - - // 3. Second access to the same credential offer URL - should fail with replay protection error - CredentialOfferResponse response = oauth.oid4vc() - .credentialOfferRequest(nonce) - .bearerToken(token) - .send(); - - assertEquals("Second access to credential offer should fail with 400 Bad Request", - Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); - assertEquals("Error type should be INVALID_CREDENTIAL_OFFER_REQUEST", - ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), - response.getError()); - assertTrue("Error description should mention that offer is not found or already consumed", - response.getErrorDescription().contains("not found") || response.getErrorDescription().contains("already consumed")); - } - - /** - * Test that different nonces work independently (each offer has its own state). - * This verifies that replay protection is per-nonce and doesn't affect other offers. - */ - @Test - public void testCredentialOfferDifferentNoncesIndependent() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - - // 1. Create first credential offer - CredentialOfferURI credentialOfferURI1 = oauth.oid4vc() - .credentialOfferUriRequest(credentialConfigurationId) - .preAuthorized(true) - .targetUser("john") - .bearerToken(token) - .send() - .getCredentialOfferURI(); - assertNotNull("First credential offer URI should not be null", credentialOfferURI1); - String nonce1 = credentialOfferURI1.getNonce(); - assertNotNull("First nonce should not be null", nonce1); - - // 2. Create second credential offer (should have different nonce) - CredentialOfferURI credentialOfferURI2 = oauth.oid4vc() - .credentialOfferUriRequest(credentialConfigurationId) - .preAuthorized(true) - .targetUser("john") - .bearerToken(token) - .send() - .getCredentialOfferURI(); - assertNotNull("Second credential offer URI should not be null", credentialOfferURI2); - String nonce2 = credentialOfferURI2.getNonce(); - assertNotNull("Second nonce should not be null", nonce2); - assertNotEquals("Nonces should be different for different offers", nonce1, nonce2); - - // 3. Access first offer - should succeed - CredentialsOffer credentialsOffer1 = oauth.oid4vc() - .credentialOfferRequest(nonce1) - .bearerToken(token) - .send() - .getCredentialsOffer(); - assertNotNull("First credential offer should not be null", credentialsOffer1); - - // 4. Access second offer - should also succeed (different nonce, independent state) - CredentialsOffer credentialsOffer2 = oauth.oid4vc() - .credentialOfferRequest(nonce2) - .bearerToken(token) - .send() - .getCredentialsOffer(); - assertNotNull("Second credential offer should not be null", credentialsOffer2); - - // 5. Verify that accessing first offer again fails (replay protection per-nonce) - CredentialOfferResponse response1 = oauth.oid4vc() - .credentialOfferRequest(nonce1) - .bearerToken(token) - .send(); - - assertEquals("First offer should fail on second access (replay protection)", - Response.Status.BAD_REQUEST.getStatusCode(), response1.getStatusCode()); - assertEquals("Error type should be INVALID_CREDENTIAL_OFFER_REQUEST", - ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), - response1.getError()); - - // 6. Verify that accessing second offer again also fails (replay protection per-nonce) - CredentialOfferResponse response2 = oauth.oid4vc() - .credentialOfferRequest(nonce2) - .bearerToken(token) - .send(); - - assertEquals("Second offer should fail on second access (replay protection)", - Response.Status.BAD_REQUEST.getStatusCode(), response2.getStatusCode()); - assertEquals("Error type should be INVALID_CREDENTIAL_OFFER_REQUEST", - ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), - response2.getError()); - } - - /** - * Test that removing the nonce entry (for replay protection) does not invalidate - * the Pre-Authorized Code. This verifies that the replay protection mechanism doesn't - * interfere with the normal token request flow using the pre-authorized code. - */ - @Test - public void testPreAuthorizedCodeValidAfterOfferConsumed() { - String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName()); - final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes() - .get(CredentialScopeModel.VC_CONFIGURATION_ID); - - // 1. Fetch the Offer URI - CredentialOfferURI credentialOfferURI = oauth.oid4vc() - .credentialOfferUriRequest(credentialConfigurationId) - .preAuthorized(true) - .targetUser("john") - .bearerToken(token) - .send() - .getCredentialOfferURI(); - assertNotNull("Credential offer URI should not be null", credentialOfferURI); - String nonce = credentialOfferURI.getNonce(); - assertNotNull("Nonce should not be null", nonce); - - // 2. Fetch the Offer JSON (this removes the nonce entry for replay protection) - CredentialsOffer credentialsOffer = oauth.oid4vc() - .credentialOfferRequest(nonce) - .bearerToken(token) - .send() - .getCredentialsOffer(); - assertNotNull("Credential offer should not be null", credentialsOffer); - String preAuthorizedCode = credentialsOffer.getPreAuthorizedCode(); - assertNotNull("Pre-authorized code value should not be null", preAuthorizedCode); - - // 3. Immediately perform the Token Request (Pre-Authorized Code Grant) using the valid code - CredentialIssuer credentialIssuer = oauth.oid4vc() - .issuerMetadataRequest() - .endpoint(credentialsOffer.getIssuerMetadataUrl()) - .send() - .getMetadata(); - OIDCConfigurationRepresentation openidConfig = oauth - .wellknownRequest() - .url(credentialIssuer.getAuthorizationServers().get(0)) - .send() - .getOidcConfiguration(); - assertNotNull("Token endpoint should be present", openidConfig.getTokenEndpoint()); - - AccessTokenResponse accessTokenResponse = oauth.oid4vc() - .preAuthorizedCodeGrantRequest(preAuthorizedCode) - .send(); - assertEquals("Token request should succeed even after nonce is removed for replay protection", - HttpStatus.SC_OK, - accessTokenResponse.getStatusCode()); - assertNotNull("Access token should be present", accessTokenResponse.getAccessToken()); - assertFalse("Access token should not be empty", accessTokenResponse.getAccessToken().isEmpty()); - } -}