mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-18 18:37:54 -05:00
[OID4VCI] Make natural_person configuration available in all formats
Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
parent
80839bfc44
commit
d2150a19d5
8 changed files with 91 additions and 55 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue