From cc78fd7ca022242614536806ca38c718c9be96b0 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Mon, 29 Jul 2024 09:32:48 +0200 Subject: [PATCH] Provided keycloak with a protocol mapper, that can allow to optionally add iat and nbf claims to VCs (#31620) closes #31581 Signed-off-by: Francis Pouatcha --- .../OID4VCIssuedAtTimeClaimMapper.java | 144 ++++++++++++++++++ .../org.keycloak.protocol.ProtocolMapper | 1 + .../OID4VCSdJwtIssuingEndpointTest.java | 16 +- .../oid4vc/issuance/signing/OID4VCTest.java | 24 ++- 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java new file mode 100644 index 0000000000..1484032d4d --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java @@ -0,0 +1,144 @@ +/* + * 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.protocol.oid4vc.issuance.mappers; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.ProtocolMapper; +import org.keycloak.protocol.oid4vc.model.CredentialSubject; +import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.provider.ProviderConfigProperty; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Map issuance date to the credential, under the default claim name "iat" + *

+ * subjectProperty can be used to change the claim name. + *

+ * Source of the information can either be computed, or read from the VerifiableCredential object + * bearing other claims. Default is the value in the verifiable credential. + *

+ * We will use the java.time.temporal.ChronoUnit enum values to help flatten down the time. + * + * @author Francis Pouatcha + */ +public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper { + + public static final String MAPPER_ID = "oid4vc-issued-at-time-claim-mapper"; + + // Omit value if defaults to "iat" + public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty"; + + // We will use the java.time.temporal.ChronoUnit enum values to help flatten down the time. + // Omit property if no truncation. + public static final String TRUNCATE_TO_TIME_UNIT_KEY = "truncateToTimeUnit"; + + // Time computed (COMPUTE) or taken from the verifiable credential (VC). + // Defaults to VC. Falls back to COMPUTE. + public static final String VALUE_SOURCE = "valueSource"; + + private static final List CONFIG_PROPERTIES = new ArrayList<>(); + + static { + ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty(); + subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY); + subjectPropertyNameConfig.setLabel("Time Claim Name"); + subjectPropertyNameConfig.setHelpText("Name of this time claim. Default is iat"); + subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE); + subjectPropertyNameConfig.setDefaultValue("iat"); + CONFIG_PROPERTIES.add(subjectPropertyNameConfig); + + ProviderConfigProperty truncateToTimeUnit = new ProviderConfigProperty(); + truncateToTimeUnit.setName(TRUNCATE_TO_TIME_UNIT_KEY); + truncateToTimeUnit.setLabel("Truncate To Time Unit"); + truncateToTimeUnit.setHelpText("Truncate time to the first second of the MINUTES, HOURS, HALF_DAYS, DAYS, WEEKS, MONTHS or YEARS. Such as to prevent correlation of credentials based on this time value."); + truncateToTimeUnit.setType(ProviderConfigProperty.LIST_TYPE); + truncateToTimeUnit.setOptions(List.of("MINUTES", "HOURS", "HALF_DAYS", "DAYS", "WEEKS", "MONTHS", "YEARS")); + CONFIG_PROPERTIES.add(truncateToTimeUnit); + + ProviderConfigProperty valueSource = new ProviderConfigProperty(); + valueSource.setName(VALUE_SOURCE); + valueSource.setLabel("Source of Value"); + valueSource.setHelpText("Tells the protocol mapper where to get the information. For now: COMPUTE or VC. Default is COMPUTE, in which this protocol mapper computes the current time in seconds. With value `VC`, the time is read from the verifiable credential issuance date field."); + valueSource.setType(ProviderConfigProperty.LIST_TYPE); + valueSource.setOptions(List.of("COMPUTE", "VC")); + valueSource.setDefaultValue("COMPUTE"); + CONFIG_PROPERTIES.add(valueSource); + } + + @Override + protected List getIndividualConfigProperties() { + return CONFIG_PROPERTIES; + } + + public void setClaimsForCredential(VerifiableCredential verifiableCredential, + UserSessionModel userSessionModel) { + Instant iat = Optional.ofNullable(mapperModel.getConfig()) + .flatMap(config -> Optional.ofNullable(config.get(VALUE_SOURCE))) + .filter(valueSource -> Objects.equals(valueSource, "COMPUTE")) + .map(valueSource -> Instant.now()) + .orElseGet(() -> Optional.ofNullable(verifiableCredential.getIssuanceDate()) + .orElse(Instant.now())); + + // truncate is possible. Return iat if not. + Instant iatTrunc = Optional.ofNullable(mapperModel.getConfig()) + .flatMap(config -> Optional.ofNullable(config.get(TRUNCATE_TO_TIME_UNIT_KEY))) + .filter(i -> i.isEmpty()) + .map(ChronoUnit::valueOf) + .map(iat::truncatedTo) + .orElse(iat); + + // Set the value + String propertyName = Optional.ofNullable(mapperModel.getConfig()) + .map(config -> config.get(SUBJECT_PROPERTY_CONFIG_KEY)) + .orElse("iat"); + CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject(); + credentialSubject.setClaims(propertyName, iatTrunc.getEpochSecond()); + } + + @Override + public void setClaimsForSubject(Map claims, UserSessionModel userSessionModel) { + // NoOp + } + + @Override + public String getDisplayType() { + return "Issuance Date Claim Mapper"; + } + + @Override + public String getHelpText() { + return "Allows to set the issuance date credential subject."; + } + + @Override + public ProtocolMapper create(KeycloakSession session) { + return new OID4VCIssuedAtTimeClaimMapper(); + } + + @Override + public String getId() { + return MAPPER_ID; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index bf54b6c575..f17cf7a5fb 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -54,6 +54,7 @@ org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTargetRoleMapper org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTypeMapper org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCContextMapper +org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper org.keycloak.protocol.oidc.mappers.SessionStateMapper org.keycloak.protocol.oidc.mappers.SubMapper org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 0bf1e5aa7a..8939a349bb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -61,6 +61,7 @@ import org.keycloak.util.JsonSerialization; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.temporal.ChronoUnit; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -226,7 +227,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { "sha-256", "did:web:issuer.org", 2, - List.of(), + List.of("iat", "nbf"), Optional.empty(), VerifiableCredentialType.from("https://credentials.example.com/test-credential"), CredentialConfigId.from("test-credential")); @@ -240,7 +241,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { "sha-256", "did:web:issuer.org", 0, - List.of("given_name"), + List.of("given_name", "iat", "nbf"), Optional.empty(), VerifiableCredentialType.from("https://credentials.example.com/identity_credential"), CredentialConfigId.from("IdentityCredential")); @@ -273,6 +274,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { "hashAlgorithm", List.of("sha-256"), "decoys", List.of("0"), "vct", List.of("https://credentials.example.com/identity_credential"), + "visibleClaims", List.of("iat,nbf"), "vcConfigId", List.of("IdentityCredential") ) )); @@ -293,6 +295,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { "hashAlgorithm", List.of("sha-256"), "decoys", List.of("2"), "vct", List.of("https://credentials.example.com/test-credential"), + "visibleClaims", List.of("iat,nbf"), "vcConfigId", List.of("test-credential") ) )); @@ -337,9 +340,13 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { getUserAttributeMapper("lastName", "lastName", "test-credential"), getIdMapper("test-credential"), getStaticClaimMapper("test-credential", "test-credential"), + getIssuedAtTimeMapper(null, ChronoUnit.HOURS.name(), "COMPUTE","test-credential"), + getIssuedAtTimeMapper("nbf", null, "COMPUTE","test-credential"), getUserAttributeMapper("given_name", "firstName", "identity_credential"), - getUserAttributeMapper("family_name", "lastName", "identity_credential") + getUserAttributeMapper("family_name", "lastName", "identity_credential"), + getIssuedAtTimeMapper(null, ChronoUnit.MINUTES.name(), "COMPUTE","identity_credential"), + getIssuedAtTimeMapper("nbf", ChronoUnit.SECONDS.name(), "COMPUTE","identity_credential") ) ); return clientRepresentation; @@ -391,7 +398,8 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { assertTrue("The credentials should include the email claim.", disclosureMap.containsKey("email")); assertEquals("email claim incorrectly mapped.", disclosureMap.get("email").get(2).asText(), "john@email.cz"); - } + assertNotNull("Test credential shall include an iat claim.", jsonWebToken.getIat()); + assertNotNull("Test credential shall include an nbf claim.", jsonWebToken.getNbf()); } } } 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 024e729554..bf18a47e17 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 @@ -27,10 +27,10 @@ import org.keycloak.common.util.PemUtils; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.utils.DefaultKeyProviders; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; +import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper; import org.keycloak.protocol.oid4vc.model.CredentialSubject; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; @@ -53,8 +53,10 @@ import java.security.PublicKey; import java.security.Security; import java.security.cert.Certificate; import java.time.Instant; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; /** @@ -366,4 +368,24 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { ); return protocolMapperRepresentation; } + + protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource, String supportedCredentialTypes) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + subjectProperty + "-oid4vc-issued-at-time-claim-mapper"); + protocolMapperRepresentation.setProtocol("oid4vc"); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper"); + + Map configMap = new HashMap<>(); + configMap.put("supportedCredentialTypes", supportedCredentialTypes); + Optional.ofNullable(subjectProperty) + .ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.SUBJECT_PROPERTY_CONFIG_KEY, value)); + Optional.ofNullable(truncateToTimeUnit) + .ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.TRUNCATE_TO_TIME_UNIT_KEY, value)); + Optional.ofNullable(valueSource) + .ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.VALUE_SOURCE, value)); + + protocolMapperRepresentation.setConfig(configMap); + return protocolMapperRepresentation; + } }