[OID4VCI] Make natural_person configuration available in all formats

Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler 2025-12-11 11:42:04 +01:00 committed by Marek Posolda
parent 80839bfc44
commit d2150a19d5
8 changed files with 91 additions and 55 deletions

View file

@ -43,13 +43,18 @@ public interface VCFormat {
// https://github.com/keycloak/keycloak/issues/44875
String[] SUPPORTED_FORMATS = new String[]{JWT_VC, SD_JWT_VC};
static String getFromScope(String scope) {
String format = SD_JWT_VC; // default format
if (scope.toLowerCase().endsWith("_jwt")) format = JWT_VC;
else if (scope.toLowerCase().endsWith("_ld")) format = LDP_VC;
return format;
}
static String getScopeSuffix(String value) {
switch (value) {
case JWT_VC: return "_jwt";
case LDP_VC: return "_ld";
case SD_JWT_VC: return "_sd";
default:
throw new IllegalStateException("Unexpected value: " + value);
}
String suffix = "";
if (JWT_VC.equals(value)) suffix = "_jwt";
else if (LDP_VC.equals(value)) suffix = "_ld";
else if (SD_JWT_VC.equals(value)) suffix = "_sd";
return suffix;
}
}

View file

@ -23,6 +23,7 @@ import java.util.Map;
import jakarta.ws.rs.core.Response;
import org.keycloak.Config;
import org.keycloak.VCFormat;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
@ -49,17 +50,16 @@ import org.keycloak.services.ErrorResponseException;
import org.jboss.logging.Logger;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
import static org.keycloak.VCFormat.SD_JWT_VC;
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;
@ -89,6 +89,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
private static final String FIRST_NAME_MAPPER = "first-name";
public static final String PROTOCOL_ID = OID4VCIConstants.OID4VC_PROTOCOL;
public static final String CREDENTIAL_TYPE_NATURAL_PERSON = "natural_person";
private final Map<String, ProtocolMapperModel> builtins = new HashMap<>();
@ -126,18 +127,23 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
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", 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, false);
for (String format : VCFormat.SUPPORTED_FORMATS) {
String scopeName = CREDENTIAL_TYPE_NATURAL_PERSON + VCFormat.getScopeSuffix(format);
ClientScopeModel clientScope = KeycloakModelUtils.getClientScopeByName(newRealm, scopeName);
if (clientScope == null) {
LOGGER.debugf("Add client scope: %s", scopeName);
clientScope = newRealm.addClientScope(String.format("%s_%s", OID4VC_PROTOCOL, scopeName));
clientScope.setDescription("OID4VCI credential scope that represents a natural person in format: " + format);
clientScope.setProtocol(OID4VC_PROTOCOL);
clientScope.addProtocolMapper(builtins.get(SUBJECT_ID_MAPPER));
clientScope.addProtocolMapper(builtins.get(EMAIL_MAPPER));
clientScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER));
clientScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER));
ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope);
addClientScopeDefaults(clientScopeRep);
RepresentationToModel.updateClientScope(clientScopeRep, clientScope);
}
}
}
@ -148,25 +154,33 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
@Override
public void addClientScopeDefaults(ClientScopeRepresentation clientScope) {
String scopeName = clientScope.getName();
clientScope.getAttributes().computeIfAbsent(FORMAT, k -> VCFormat.getFromScope(scopeName));
String format = clientScope.getAttributes().get(FORMAT);
int idx = scopeName.lastIndexOf(VCFormat.getScopeSuffix(format));
String credentialType = idx > 0 ? scopeName.substring(0, idx) : scopeName;
// 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 -> <generate did or use the realm url>);
// Leaving vc.issuer_did undefined results in the realm's url being used as the value for the Issuer's ID (iss).
// clientScope.getAttributes().computeIfAbsent(ISSUER_DID, k -> <generate did or use the realm url>)
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));
clientScope.getAttributes().putIfAbsent(CONFIGURATION_ID, scopeName);
clientScope.getAttributes().putIfAbsent(TYPES, credentialType);
clientScope.getAttributes().putIfAbsent(CONTEXTS, credentialType);
clientScope.getAttributes().putIfAbsent(VCT, credentialType);
clientScope.getAttributes().putIfAbsent(CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
clientScope.getAttributes().putIfAbsent(HASH_ALGORITHM, HASH_ALGORITHM_DEFAULT);
clientScope.getAttributes().putIfAbsent(TOKEN_JWS_TYPE, TOKEN_TYPE_DEFAULT);
clientScope.getAttributes().putIfAbsent(EXPIRY_IN_SECONDS, String.valueOf(EXPIRY_IN_SECONDS_DEFAULT));
if (SD_JWT_VC.equals(format)) {
clientScope.getAttributes().putIfAbsent(SD_JWT_NUMBER_OF_DECOYS, String.valueOf(SD_JWT_DECOYS_DEFAULT));
clientScope.getAttributes().putIfAbsent(SD_JWT_VISIBLE_CLAIMS, SD_JWT_VISIBLE_CLAIMS_DEFAULT);
}
}
@Override
@ -224,10 +238,4 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
}
// Private ---------------------------------------------------------------------------------------------------------
private void addClientScopeDefaults(ClientScopeModel clientScope) {
ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope);
addClientScopeDefaults(clientScopeRep);
RepresentationToModel.updateClientScope(clientScopeRep, clientScope);
}
}

View file

@ -1130,7 +1130,7 @@ public class TestingResourceProvider implements RealmResourceProvider {
String code = "urn:oid4vci:code:" + UUID.randomUUID();
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of("oid4vc_natural_person"))
.setCredentialConfigurationIds(List.of("oid4vc_natural_person_sd"))
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(
new PreAuthorizedCode().setPreAuthorizedCode(code)));

View file

@ -91,8 +91,8 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String appUsername = "alice";
String credScopeName = jwtTypeCredentialScopeName;
String credConfigId = jwtTypeCredentialConfigurationIdName;
String credScopeName = jwtTypeNaturalPersonScopeName;
String credConfigId = jwtTypeNaturalPersonScopeName;
class TestContext {
boolean preAuthorized;
@ -461,6 +461,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
private void verifyCredentialResponse(TestContext ctx, CredentialResponse credResponse) throws Exception {
String issuer = ctx.issuerMetadata.getCredentialIssuer();
String scope = ctx.credentialConfiguration.getScope();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null", credentialObj);
@ -468,11 +469,11 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String expUsername = ctx.appUser != null ? ctx.appUser : appUsername;
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
assertEquals(issuer, jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(List.of(scope), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(URI.create(issuer), credential.getIssuer());
assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}

View file

@ -47,8 +47,7 @@ public class OID4VCIWellKnownProviderTest extends OID4VCTest {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session -> {
OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session);
CredentialIssuer credentialIssuer = oid4VCIssuerWellKnownProvider.getIssuerMetadata();
CredentialIssuer credentialIssuer = new OID4VCIssuerWellKnownProvider(session).getIssuerMetadata();
assertEquals("Only one asymmetric encryption key is present in the realm.",
1,
credentialIssuer.getCredentialResponseEncryption()

View file

@ -155,9 +155,11 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpointTest.class);
protected static ClientScopeRepresentation jwtTypeNaturalPersonClientScope;
protected static ClientScopeRepresentation sdJwtTypeNaturalPersonClientScope;
protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope;
protected static ClientScopeRepresentation jwtTypeCredentialClientScope;
protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope;
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
protected CloseableHttpClient httpClient;
@ -195,12 +197,11 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
session);
SdJwtCredentialBuilder sdJwtCredentialBuilder = new SdJwtCredentialBuilder();
return prepareIssuerEndpoint(
session,
authenticator,
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder)
Map<String, CredentialBuilder> credentialBuilders = Map.of(
jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder
);
return prepareIssuerEndpoint(session, authenticator, credentialBuilders);
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(
@ -229,6 +230,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
testRealm().update(realmRep);
// Lookup the pre-installed oid4vc_natural_person client scope
jwtTypeNaturalPersonClientScope = requireExistingClientScope(jwtTypeNaturalPersonScopeName);
sdJwtTypeNaturalPersonClientScope = requireExistingClientScope(sdJwtTypeNaturalPersonScopeName);
// Register the optional client scopes
@ -260,6 +262,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
String clientId = client.getClientId();
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(jwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId);

View file

@ -31,6 +31,7 @@ import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Response;
import org.keycloak.VCFormat;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
@ -876,6 +877,24 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
}
}
@Test
public void verifyDefaultCredentialConfigurations() throws IOException {
getTestingClient()
.server(TEST_REALM_NAME)
.run(session -> {
CredentialIssuer issuerMetadata = new OID4VCIssuerWellKnownProvider(session).getIssuerMetadata();
Map<String, SupportedCredentialConfiguration> supported = issuerMetadata.getCredentialsSupported();
String credType = "oid4vc_natural_person";
for (String format : VCFormat.SUPPORTED_FORMATS) {
String key = credType + VCFormat.getScopeSuffix(format);
SupportedCredentialConfiguration credConfig = supported.get(key);
assertNotNull("No " + key, credConfig);
assertEquals(credConfig.getId(), credConfig.getScope());
assertEquals(format, credConfig.getFormat());
}
});
}
private void testBatchSizeValidation(KeycloakTestingClient testingClient, String batchSize, boolean shouldBePresent, Integer expectedValue) {
testingClient

View file

@ -135,7 +135,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected static final KeyWrapper RSA_KEY = getRsaKey();
protected static final String sdJwtTypeNaturalPersonScopeName = "oid4vc_natural_person";
protected static final String jwtTypeNaturalPersonScopeName = "oid4vc_natural_person_jwt";
protected static final String sdJwtTypeNaturalPersonScopeName = "oid4vc_natural_person_sd";
protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential";
protected static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id";