From 131e2357a954153f761383769c1328b6ed947d89 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 30 Oct 2025 09:07:37 +0100 Subject: [PATCH] Cannot issue vc of type oid4vc_natural_person Signed-off-by: Thomas Diesler --- .../models/oid4vci/CredentialScopeModel.java | 2 +- .../oid4vc/OID4VCLoginProtocolFactory.java | 151 +++++++++++------- .../signing/OID4VCIssuerEndpointTest.java | 18 +++ ...4VCSdJwtPreInstalledNaturalPersonTest.java | 95 +++++++++++ .../oid4vc/issuance/signing/OID4VCTest.java | 4 +- 5 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtPreInstalledNaturalPersonTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index b7c604660bc..d1ea4c0186c 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -47,7 +47,7 @@ public class CredentialScopeModel implements ClientScopeModel { public static final String FORMAT_DEFAULT = "dc+sd-jwt"; public static final String HASH_ALGORITHM_DEFAULT = "SHA-256"; public static final String TOKEN_TYPE_DEFAULT = "JWS"; - public static final int EXPIRY_IN_SECONDS_DEFAULT = 31536000; + public static final int EXPIRY_IN_SECONDS_DEFAULT = 31536000; // 1 year public static final String CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT = "jwk"; /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index b76abb2b37c..549c9bc499f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -26,12 +26,13 @@ import org.keycloak.constants.Oid4VciConstants; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; -import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; @@ -41,6 +42,29 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; +import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL; +import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CONFIGURATION_ID; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CONTEXTS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CREDENTIAL_IDENTIFIER; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.EXPIRY_IN_SECONDS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.EXPIRY_IN_SECONDS_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.FORMAT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.FORMAT_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.HASH_ALGORITHM; +import static org.keycloak.models.oid4vci.CredentialScopeModel.HASH_ALGORITHM_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.INCLUDE_IN_METADATA; +import static org.keycloak.models.oid4vci.CredentialScopeModel.SD_JWT_DECOYS_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.SD_JWT_NUMBER_OF_DECOYS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS; +import static org.keycloak.models.oid4vci.CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.TOKEN_JWS_TYPE; +import static org.keycloak.models.oid4vci.CredentialScopeModel.TOKEN_TYPE_DEFAULT; +import static org.keycloak.models.oid4vci.CredentialScopeModel.TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.VCT; + /** * Factory for creating all OID4VC related endpoints and the default mappers. * @@ -59,7 +83,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE public static final String PROTOCOL_ID = Oid4VciConstants.OID4VC_PROTOCOL; - private Map builtins = new HashMap<>(); + private final Map builtins = new HashMap<>(); @Override public void init(Config.Scope config) { @@ -91,69 +115,74 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE return new OID4VCIssuerEndpoint(keycloakSession); } - @Override - public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) { - LOGGER.debugf("Create default scopes for realm %s", newRealm.getName()); + @Override + public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToExistingClients) { + LOGGER.debugf("Create default scopes for realm %s", newRealm.getName()); - ClientScopeModel naturalPersonScope = KeycloakModelUtils.getClientScopeByName(newRealm, "natural_person"); - if (naturalPersonScope == null) { - LOGGER.debug("Add natural person scope"); - naturalPersonScope = newRealm.addClientScope(String.format("%s_%s", Oid4VciConstants.OID4VC_PROTOCOL, "natural_person")); - naturalPersonScope.setDescription("OIDC$VP Scope, that adds all properties required for a natural person."); - naturalPersonScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL); - naturalPersonScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(EMAIL_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER)); - naturalPersonScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER)); - newRealm.addDefaultClientScope(naturalPersonScope, true); - } - } + ClientScopeModel naturalPersonScope = KeycloakModelUtils.getClientScopeByName(newRealm, "natural_person"); + if (naturalPersonScope == null) { + LOGGER.debug("Add natural person scope"); + naturalPersonScope = newRealm.addClientScope(String.format("%s_%s", OID4VC_PROTOCOL, "natural_person")); + naturalPersonScope.setDescription("OID4VCI Scope, that adds properties required for a natural person."); + naturalPersonScope.setProtocol(OID4VC_PROTOCOL); + naturalPersonScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(EMAIL_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER)); + naturalPersonScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER)); + addClientScopeDefaults(naturalPersonScope); + newRealm.addDefaultClientScope(naturalPersonScope, true); + } + } - @Override - public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) { - //no-op - } + @Override + public void setupClientDefaults(ClientRepresentation rep, ClientModel newClient) { + //no-op + } - @Override - public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CONFIGURATION_ID, k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CREDENTIAL_IDENTIFIER, - k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.TYPES, k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CONTEXTS, k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.VCT, k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.ISSUER_DID, k -> clientScope.getName()); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.FORMAT, - k -> CredentialScopeModel.FORMAT_DEFAULT); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS, - k -> CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.SD_JWT_NUMBER_OF_DECOYS, - k -> String.valueOf(CredentialScopeModel.SD_JWT_DECOYS_DEFAULT)); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS, - k -> CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS_DEFAULT); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.HASH_ALGORITHM, - k -> CredentialScopeModel.HASH_ALGORITHM_DEFAULT); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.TOKEN_JWS_TYPE, - k -> CredentialScopeModel.TOKEN_TYPE_DEFAULT); - clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.EXPIRY_IN_SECONDS, - k -> String.valueOf(CredentialScopeModel.EXPIRY_IN_SECONDS_DEFAULT)); - } + @Override + public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { - @Override - public LoginProtocol create(KeycloakSession session) { - return null; - } + // Note, there is no sensible default for the Issuer's DID unless we generate a did:key:* from the signing key + // Leaving vc.issuer_did undefined results in the realm's url being used as the value for the Issuer's ID (iss), which is fine. + // clientScope.getAttributes().computeIfAbsent(ISSUER_DID, k -> ); - @Override - public String getId() { - return Oid4VciConstants.OID4VC_PROTOCOL; - } + clientScope.getAttributes().putIfAbsent(INCLUDE_IN_TOKEN_SCOPE, "true"); + clientScope.getAttributes().putIfAbsent(INCLUDE_IN_METADATA, "true"); + clientScope.getAttributes().computeIfAbsent(CONFIGURATION_ID, k -> clientScope.getName()); + clientScope.getAttributes().computeIfAbsent(CREDENTIAL_IDENTIFIER, k -> clientScope.getName()); + clientScope.getAttributes().computeIfAbsent(TYPES, k -> clientScope.getName()); + clientScope.getAttributes().computeIfAbsent(CONTEXTS, k -> clientScope.getName()); + clientScope.getAttributes().computeIfAbsent(VCT, k -> clientScope.getName()); + clientScope.getAttributes().computeIfAbsent(FORMAT, k -> FORMAT_DEFAULT); + clientScope.getAttributes().computeIfAbsent(CRYPTOGRAPHIC_BINDING_METHODS, k -> CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); + clientScope.getAttributes().computeIfAbsent(SD_JWT_NUMBER_OF_DECOYS, k -> String.valueOf(SD_JWT_DECOYS_DEFAULT)); + clientScope.getAttributes().computeIfAbsent(SD_JWT_VISIBLE_CLAIMS, k -> SD_JWT_VISIBLE_CLAIMS_DEFAULT); + clientScope.getAttributes().computeIfAbsent(HASH_ALGORITHM, k -> HASH_ALGORITHM_DEFAULT); + clientScope.getAttributes().computeIfAbsent(TOKEN_JWS_TYPE, k -> TOKEN_TYPE_DEFAULT); + clientScope.getAttributes().computeIfAbsent(EXPIRY_IN_SECONDS, k -> String.valueOf(EXPIRY_IN_SECONDS_DEFAULT)); + } - /** - * defines the option-order in the admin-ui - */ - @Override - public int order() { - return OIDCLoginProtocolFactory.UI_ORDER - 20; - } + @Override + public LoginProtocol create(KeycloakSession session) { + return null; + } + + @Override + public String getId() { + return OID4VC_PROTOCOL; + } + + /** + * defines the option-order in the admin-ui + */ + @Override + public int order() { + return OIDCLoginProtocolFactory.UI_ORDER - 20; + } + + private void addClientScopeDefaults(ClientScopeModel clientScope) { + ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope); + addClientScopeDefaults(clientScopeRep); + RepresentationToModel.updateClientScope(clientScopeRep, clientScope); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 92a572c2b32..9216b465459 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -115,6 +115,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.keycloak.jose.jwe.JWEConstants.A256GCM; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP; import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256; @@ -132,6 +133,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpointTest.class); + protected static ClientScopeRepresentation sdJwtTypeNaturalPersonClientScope; protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope; protected static ClientScopeRepresentation jwtTypeCredentialClientScope; protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope; @@ -199,6 +201,9 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { httpClient = HttpClientBuilder.create().build(); client = testRealm().clients().findByClientId(clientId).get(0); + // Lookup the pre-installed oid4vc_natural_person client scope + sdJwtTypeNaturalPersonClientScope = requireExistingClientScope(sdJwtTypeNaturalPersonScopeName); + // Register the optional client scopes sdJwtTypeCredentialClientScope = registerOptionalClientScope(sdJwtTypeCredentialScopeName, null, @@ -307,6 +312,19 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { return clientScope; } + private ClientScopeRepresentation requireExistingClientScope(String scopeName) { + + // Check if the client scope already exists + List existingScopes = testRealm().clientScopes().findAll(); + for (ClientScopeRepresentation existingScope : existingScopes) { + if (existingScope.getName().equals(scopeName)) { + return existingScope; // Reuse existing scope + } + } + fail("No such client scope: " + scopeName); + return null; + } + private List resolveProtocolMappers(String protocolMapperReferenceFile) { if (protocolMapperReferenceFile == null) { return null; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtPreInstalledNaturalPersonTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtPreInstalledNaturalPersonTest.java new file mode 100644 index 00000000000..990cd91e2d4 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtPreInstalledNaturalPersonTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.oid4vc.issuance.signing; + +import org.junit.Test; +import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider; +import org.keycloak.protocol.oid4vc.model.Claim; +import org.keycloak.protocol.oid4vc.model.Format; +import org.slf4j.LoggerFactory; + +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.keycloak.models.oid4vci.CredentialScopeModel.CONFIGURATION_ID; + +/** + * OID4VCI testing for the pre-installed oid4vc_natural_person + * + */ +public class OID4VCSdJwtPreInstalledNaturalPersonTest extends OID4VCIssuerEndpointTest { + + /** + * This is testing the configuration exposed by OID4VCIssuerWellKnownProvider. + */ + @Test + public void testGetSdJwtConfigFromMetadata() { + final String scopeName = sdJwtTypeNaturalPersonClientScope.getName(); + final String credentialConfigurationId = sdJwtTypeNaturalPersonClientScope.getAttributes().get(CONFIGURATION_ID); + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/" + TEST_REALM_NAME; + testingClient + .server(TEST_REALM_NAME) + .run((session -> { + var log = LoggerFactory.getLogger(OID4VCSdJwtPreInstalledNaturalPersonTest.class); + var oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session); + var credentialIssuer = oid4VCIssuerWellKnownProvider.getIssuerMetadata(); + var credentialsSupported = credentialIssuer.getCredentialsSupported(); + + // Log existing credential configurations + credentialsSupported.forEach((k, v) -> { + log.info("CredentialsSupported: {}", k); + }); + + var jwtVcConfig = credentialsSupported.get(credentialConfigurationId); + var credentialBuildConfig = jwtVcConfig.getCredentialBuildConfig(); + assertEquals(scopeName, jwtVcConfig.getScope()); + assertEquals(expectedIssuer, credentialBuildConfig.getCredentialIssuer()); + assertEquals(Format.SD_JWT_VC, jwtVcConfig.getFormat()); + + var credentialMetadata = jwtVcConfig.getCredentialMetadata(); + var jwtVcClaims = credentialMetadata.getClaims().stream() + .collect(Collectors.toMap(Claim::getName, Function.identity())); + { + Claim claim = jwtVcClaims.get("id"); + assertEquals("id", claim.getPath().get(0)); + assertFalse(claim.isMandatory()); + assertNull(claim.getDisplay()); + } + { + Claim claim = jwtVcClaims.get("email"); + assertEquals("email", claim.getPath().get(0)); + assertFalse(claim.isMandatory()); + assertNull(claim.getDisplay()); + } + { + Claim claim = jwtVcClaims.get("firstName"); + assertEquals("firstName", claim.getPath().get(0)); + assertFalse(claim.isMandatory()); + assertNull(claim.getDisplay()); + } + { + Claim claim = jwtVcClaims.get("familyName"); + assertEquals("familyName", claim.getPath().get(0)); + assertFalse(claim.isMandatory()); + assertNull(claim.getDisplay()); + } + })); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 368e2ec9a6b..f6cc896a417 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -118,7 +118,9 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { protected static final KeyWrapper RSA_KEY = getRsaKey(); - protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential"; + protected static final String sdJwtTypeNaturalPersonScopeName = "oid4vc_natural_person"; + + protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential"; protected static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id"; protected static final String jwtTypeCredentialScopeName = "jwt-credential";