From c08ed20f781ddd01c7ca03d413b944dc7651b5d3 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 30 Jan 2026 17:29:47 +0100 Subject: [PATCH] [OID4VCI] Add support for user did as subject id (#45008) closes #45006 Signed-off-by: Thomas Diesler --- .../org/keycloak/common/util/DerUtils.java | 10 + .../main/java/org/keycloak/util/Base58.java | 162 ++++++++++ .../main/java/org/keycloak/util/DIDUtils.java | 299 ++++++++++++++++++ .../java/org/keycloak/models/UserModel.java | 1 + .../oid4vc/OID4VCLoginProtocolFactory.java | 2 +- .../mappers/OID4VCSubjectIdMapper.java | 42 ++- .../signing/AbstractCredentialSigner.java | 2 +- .../oid4vc/model/CredentialBuildConfig.java | 16 +- .../DeclarativeUserProfileProvider.java | 2 +- ...DeclarativeUserProfileProviderFactory.java | 38 ++- .../userprofile/config/UPConfigUtils.java | 26 +- .../config/keycloak-default-user-profile.json | 167 ++++++---- .../issuance/OID4VCIUserDidAttributeTest.java | 86 +++++ .../mappers/OID4VCTargetRoleMapperTest.java | 2 +- .../signing/OID4VCIssuerEndpointTest.java | 28 +- .../OID4VCSdJwtIssuingEndpointTest.java | 50 ++- .../oid4vc/issuance/signing/OID4VCTest.java | 30 ++ .../oid4vc/test-credential-mappers.json | 2 +- .../account/messages/messages_en.properties | 1 + 19 files changed, 836 insertions(+), 130 deletions(-) create mode 100644 core/src/main/java/org/keycloak/util/Base58.java create mode 100644 core/src/main/java/org/keycloak/util/DIDUtils.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIUserDidAttributeTest.java diff --git a/common/src/main/java/org/keycloak/common/util/DerUtils.java b/common/src/main/java/org/keycloak/common/util/DerUtils.java index 5652d547553..45f3572b9a2 100755 --- a/common/src/main/java/org/keycloak/common/util/DerUtils.java +++ b/common/src/main/java/org/keycloak/common/util/DerUtils.java @@ -29,6 +29,7 @@ import java.security.cert.X509Certificate; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; import org.keycloak.common.crypto.CryptoIntegration; @@ -55,6 +56,15 @@ public final class DerUtils { return decodePrivateKey(keyBytes); } + public static PublicKey decodePublicKey(String encoded) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException { + return decodePublicKey(encoded, "RSA"); + } + + public static PublicKey decodePublicKey(String encoded, String type) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException { + byte[] der = Base64.getDecoder().decode(encoded); + return decodePublicKey(der, type); + } + public static PublicKey decodePublicKey(byte[] der) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException { return decodePublicKey(der, "RSA"); } diff --git a/core/src/main/java/org/keycloak/util/Base58.java b/core/src/main/java/org/keycloak/util/Base58.java new file mode 100644 index 00000000000..12491736392 --- /dev/null +++ b/core/src/main/java/org/keycloak/util/Base58.java @@ -0,0 +1,162 @@ +/* + * Copyright 2011 Google Inc. + * Copyright 2018 Andreas Schildbach + * + * 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.util; + + +import java.util.Arrays; + +/** + * Base58 is a way to encode Bitcoin addresses (or arbitrary data) as alphanumeric strings. + *

+ * Note that this is not the same base58 as used by Flickr, which you may find referenced around the Internet. + *

+ * You may want to consider working with {@code org.bitcoinj.core.EncodedPrivateKey} instead, which + * adds support for testing the prefix and suffix bytes commonly found in addresses. + *

+ * Satoshi explains: why base-58 instead of standard base-64 encoding? + *

+ *

+ * However, note that the encoding/decoding runs in O(n²) time, so it is not useful for large data. + *

+ * The basic idea of the encoding is to treat the data bytes as a large number represented using + * base-256 digits, convert the number to be represented using base-58 digits, preserve the exact + * number of leading zeros (which are otherwise lost during the mathematical operations on the + * numbers), and finally represent the resulting base-58 digits as alphanumeric ASCII characters. + *

+ * Replaced bitcoinj AddressFormatException with IllegalArgumentException + * Remove Bitcoin Address functionality i.e. encodeChecked, decodeChecked + */ +public class Base58 { + public static final char[] ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); + private static final char ENCODED_ZERO = ALPHABET[0]; + private static final int[] INDEXES = new int[128]; + static { + Arrays.fill(INDEXES, -1); + for (int i = 0; i < ALPHABET.length; i++) { + INDEXES[ALPHABET[i]] = i; + } + } + + /** + * Encodes the given bytes as a base58 string (no checksum is appended). + * + * @param input the bytes to encode + * @return the base58-encoded string + */ + public static String encode(byte[] input) { + if (input.length == 0) { + return ""; + } + // Count leading zeros. + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) { + ++zeros; + } + // Convert base-256 digits to base-58 digits (plus conversion to ASCII characters) + input = Arrays.copyOf(input, input.length); // since we modify it in-place + char[] encoded = new char[input.length * 2]; // upper bound + int outputStart = encoded.length; + for (int inputStart = zeros; inputStart < input.length; ) { + encoded[--outputStart] = ALPHABET[divmod(input, inputStart, 256, 58)]; + if (input[inputStart] == 0) { + ++inputStart; // optimization - skip leading zeros + } + } + // Preserve exactly as many leading encoded zeros in output as there were leading zeros in input. + while (outputStart < encoded.length && encoded[outputStart] == ENCODED_ZERO) { + ++outputStart; + } + while (--zeros >= 0) { + encoded[--outputStart] = ENCODED_ZERO; + } + // Return encoded string (including encoded leading zeros). + return new String(encoded, outputStart, encoded.length - outputStart); + } + + /** + * Decodes the given base58 string into the original data bytes. + * + * @param input the base58-encoded string to decode + * @return the decoded data bytes + * @throws AddressFormatException if the given string is not a valid base58 string + */ + public static byte[] decode(String input) { + if (input.isEmpty()) { + return new byte[0]; + } + // Convert the base58-encoded ASCII chars to a base58 byte sequence (base58 digits). + byte[] input58 = new byte[input.length()]; + for (int i = 0; i < input.length(); ++i) { + char c = input.charAt(i); + int digit = c < 128 ? INDEXES[c] : -1; + if (digit < 0) { + throw new IllegalArgumentException("Invalid character at index: " + i); + } + input58[i] = (byte) digit; + } + // Count leading zeros. + int zeros = 0; + while (zeros < input58.length && input58[zeros] == 0) { + ++zeros; + } + // Convert base-58 digits to base-256 digits. + byte[] decoded = new byte[input.length()]; + int outputStart = decoded.length; + for (int inputStart = zeros; inputStart < input58.length; ) { + decoded[--outputStart] = divmod(input58, inputStart, 58, 256); + if (input58[inputStart] == 0) { + ++inputStart; // optimization - skip leading zeros + } + } + // Ignore extra leading zeroes that were added during the calculation. + while (outputStart < decoded.length && decoded[outputStart] == 0) { + ++outputStart; + } + // Return decoded data (including original number of leading zeros). + return Arrays.copyOfRange(decoded, outputStart - zeros, decoded.length); + } + + /** + * Divides a number, represented as an array of bytes each containing a single digit + * in the specified base, by the given divisor. The given number is modified in-place + * to contain the quotient, and the return value is the remainder. + * + * @param number the number to divide + * @param firstDigit the index within the array of the first non-zero digit + * (this is used for optimization by skipping the leading zeros) + * @param base the base in which the number's digits are represented (up to 256) + * @param divisor the number to divide by (up to 256) + * @return the remainder of the division operation + */ + private static byte divmod(byte[] number, int firstDigit, int base, int divisor) { + // this is just long division which accounts for the base of the input digits + int remainder = 0; + for (int i = firstDigit; i < number.length; i++) { + int digit = (int) number[i] & 0xFF; + int temp = remainder * base + digit; + number[i] = (byte) (temp / divisor); + remainder = temp % divisor; + } + return (byte) remainder; + } +} diff --git a/core/src/main/java/org/keycloak/util/DIDUtils.java b/core/src/main/java/org/keycloak/util/DIDUtils.java new file mode 100644 index 00000000000..cc76c20ca97 --- /dev/null +++ b/core/src/main/java/org/keycloak/util/DIDUtils.java @@ -0,0 +1,299 @@ +package org.keycloak.util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.keycloak.common.util.StreamUtil; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Utility methods for DID encoding/decoding, specifically did:key for P-256 (ES256). + *

+ * Provides: + * - EC → did:key:z... encoding (multibase + multicodec + base58btc) + * - did:key → EC public key decoding + * - multicodec varint encode/decode + * - EC point normalization + *

+ * + * @author Thomas Diesler + */ +public final class DIDUtils { + + private DIDUtils() { + } + + /** + * Multicodec identifier for P-256 public keys. + * See: https://github.com/multiformats/multicodec/ + *

+ * Codec name: "p256-pub" + * Code: 0x1200 (varint-encoded into bytes: 0x80 0x24) + */ + public static final int MULTICODEC_P256_PUB = 0x1200; + public static final int MULTICODEC_P384_PUB = 0x1201; + public static final int MULTICODEC_P521_PUB = 0x1202; + public static final int MULTICODEC_JWK_JCS_PUB = 0xEB51; + + // --------------------------------------------------------------------- + // Public API – did:key encoding / decoding + // --------------------------------------------------------------------- + + /** + * Encode a ECPublicKey (P-256) into a did:key representation. + */ + public static String encodeDidKey(ECPublicKey pub) { + try { + return encodeDidKeyInternal(pub); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Decode a did:key (P-256) into an ECPublicKey. + */ + public static ECPublicKey decodeDidKey(String did) { + try { + ECPublicKey pubKey = decodeDidKeyInternal(did); + return pubKey; + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + } + + public static int getDidKeyCodec(String did) { + if (did == null || !did.startsWith("did:key:z")) + return 0; + + // Strip "did:key:z" (z = multibase base58btc) + String base58 = did.substring("did:key:z".length()); + byte[] decoded = Base58.decode(base58); + + // Read multicodec varint (LEB128) + int codec = 0; + int shift = 0; + + for (byte value : decoded) { + int b = value & 0xff; + codec |= (b & 0x7f) << shift; + if ((b & 0x80) == 0) { + break; + } + shift += 7; + } + + switch (codec) { + case MULTICODEC_P256_PUB: + case MULTICODEC_P384_PUB: + case MULTICODEC_P521_PUB: + case MULTICODEC_JWK_JCS_PUB: + return codec; + default: + return 0; + } + } + + // Private --------------------------------------------------------------------------------------------------------- + + private static String encodeDidKeyInternal(ECPublicKey pub) throws IOException { + return encodeDidKeyInternal(pub, false); + } + + private static String encodeDidKeyInternal(ECPublicKey pub, boolean useJwkJcsPub) throws IOException { + + ECParameterSpec params = pub.getParams(); + int fieldSize = params.getCurve().getField().getFieldSize(); + + // Verify P-256 => secp256r1 + if (fieldSize != 256) { + throw new IllegalArgumentException("Expected secp256r1, but key uses: " + params); + } + + byte[] x = toUnsigned32(pub.getW().getAffineX().toByteArray()); + byte[] y = toUnsigned32(pub.getW().getAffineY().toByteArray()); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + if (useJwkJcsPub) { + writeVarint(MULTICODEC_JWK_JCS_PUB, baos); + + // Minimal EC JWK (public) - order is fine if we don't strictly require JCS canonicalization + Map jwk = new LinkedHashMap<>(); + jwk.put("crv", "P-256"); + jwk.put("kty", "EC"); + jwk.put("x", Base64.getUrlEncoder().withoutPadding().encodeToString(x)); + jwk.put("y", Base64.getUrlEncoder().withoutPadding().encodeToString(y)); + String jwkJson = new ObjectMapper().writeValueAsString(jwk); + + baos.write(jwkJson.getBytes(UTF_8)); + + } else { + writeVarint(MULTICODEC_P256_PUB, baos); + + // EC uncompressed point format: 0x04 || X || Y + baos.write(0x04); + baos.write(x); + baos.write(y); + } + + return "did:key:z" + Base58.encode(baos.toByteArray()); + } + + private static ECPublicKey decodeDidKeyInternal(String did) throws GeneralSecurityException, IOException { + + if (!did.startsWith("did:key:z")) { + throw new IllegalArgumentException("Unsupported DID format: " + did); + } + + String b58 = did.substring("did:key:z".length()); + InputStream in = new ByteArrayInputStream(Base58.decode(b58)); + + // Read the multicodec varint + int codec = readVarint(in); + + byte[] x, y; + switch (codec) { + case MULTICODEC_P256_PUB: { + + // Expect 0x04 indicating an uncompressed EC point + int tag = in.read(); + if (tag != 0x04) { + throw new IllegalArgumentException("Invalid EC point tag: " + tag); + } + + x = readNBytes(in, 32); + y = readNBytes(in, 32); + break; + } + case MULTICODEC_JWK_JCS_PUB: { + // Remaining bytes are a UTF-8 encoded JWK JSON object (JCS-canonicalized) + String jwkJson = StreamUtil.readString(in, UTF_8); + + JsonNode jwk = new ObjectMapper().readTree(jwkJson); + String kty = jwk.path("kty").asText(null); + String crv = jwk.path("crv").asText(null); + String xB64 = jwk.path("x").asText(null); + String yB64 = jwk.path("y").asText(null); + + if (!"EC".equals(kty) || xB64 == null || yB64 == null) { + throw new IllegalArgumentException("Invalid EC JWK in did:key"); + } + if (!"P-256".equals(crv) && !"secp256r1".equalsIgnoreCase(crv)) { + throw new IllegalArgumentException("Unsupported JWK crv: " + crv); + } + + x = Base64.getUrlDecoder().decode(xB64); + y = Base64.getUrlDecoder().decode(yB64); + break; + } + default: + throw new IllegalArgumentException("Unexpected multicodec: 0x" + Integer.toHexString(codec)); + } + + ECPoint point = new ECPoint( + new BigInteger(1, x), + new BigInteger(1, y) + ); + + AlgorithmParameters params = AlgorithmParameters.getInstance("EC"); + params.init(new ECGenParameterSpec("secp256r1")); + ECParameterSpec paramSpec = params.getParameterSpec(ECParameterSpec.class); + ECPublicKeySpec keySpec = new ECPublicKeySpec(point, paramSpec); + + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return (ECPublicKey) keyFactory.generatePublic(keySpec); + } + + // --------------------------------------------------------------------- + // Multicodec varint (LEB128) encoding / decoding + // --------------------------------------------------------------------- + + private static void writeVarint(int value, OutputStream out) throws IOException { + while ((value & ~0x7F) != 0) { + out.write((value & 0x7F) | 0x80); // continuation bit + value >>>= 7; + } + out.write(value); + } + + private static int readVarint(InputStream in) throws IOException { + int value = 0; + int shift = 0; + + while (true) { + int b = in.read(); + if (b == -1) { + throw new EOFException("EOF while reading varint"); + } + + value |= (b & 0x7F) << shift; + + if ((b & 0x80) == 0) { + break; // final byte + } + + shift += 7; + if (shift > 28) { + throw new IllegalArgumentException("Varint too long"); + } + } + + return value; + } + + // --------------------------------------------------------------------- + // EC utility helpers + // --------------------------------------------------------------------- + + // Read exactly n bytes from the given InputStream + // + private static byte[] readNBytes(InputStream in, int n) throws IOException { + int read = 0; + byte[] bytes = new byte[n]; + while (read < n) { + int r = in.read(bytes, read, bytes.length - read); + if (r == -1) + throw new IllegalStateException("Unexpected EOF"); + read += r; + } + return bytes; + } + + // Convert a signed BigInteger byte array into a fixed 32-byte unsigned array. + // Removes sign byte or pads with zeros as needed. + private static byte[] toUnsigned32(byte[] in) { + if (in.length == 32) { + return in; + } + if (in.length > 32) { + // strip sign byte + return Arrays.copyOfRange(in, in.length - 32, in.length); + } + // pad to 32 bytes + byte[] out = new byte[32]; + System.arraycopy(in, 0, out, 32 - in.length, in.length); + return out; + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/UserModel.java b/server-spi/src/main/java/org/keycloak/models/UserModel.java index 59e33858a65..f8e25e07e8f 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModel.java @@ -38,6 +38,7 @@ public interface UserModel extends RoleMapperModel { String EMAIL = "email"; String EMAIL_PENDING = "kc.email.pending"; String EMAIL_VERIFIED = "emailVerified"; + String DID = "did"; String LOCALE = "locale"; String ENABLED = "enabled"; String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias"; 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 cb996e1bee3..62ec79ff1da 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -94,7 +94,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE @Override public void init(Config.Scope config) { - builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create(SUBJECT_ID_MAPPER, CLAIM_NAME_SUBJECT_ID, UserModel.ID)); + builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create(SUBJECT_ID_MAPPER, CLAIM_NAME_SUBJECT_ID, UserModel.DID)); builtins.put(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false)); builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false)); builtins.put(FIRST_NAME_MAPPER, OID4VCUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false)); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java index 8c225473b77..805f396e5a7 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java @@ -39,7 +39,43 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; /** * Sets an ID for the credential subject, either from User ID or by attribute mapping - * + *

+ * A Verifiable Credential (VC) is often bound to a Holder's Digital Identity (DID). + * The Holder's DID is in turn bound to Key Material that the Issuer + Verifier can discover from the DID Document. + * In case of "did:key:..." the Public Key is already encoded in the DID. + *

+ * Here, we make sure that the default UserProfile has a 'did' attribute when --feature=oid4vc-vci is enabled. + * Conceptually, it is however debatable whether the Issuer should know even one of the Holder's DIDs + *

+ * In future, it may be possible that ... + *

+ * * The Holder communicates the DID at the time of Authorization + * * The Issuer then verifies Holder possession of the associated Key Material + * * The Issuer then somehow associates the AuthorizationRequest with a registered User + * * The VC is then issued to the Holder without the Issuer needing to remember that Holder DID + *

+ * This kind of Authorization protocol is for example required by EBSI, which we aim to become compatible with. + * https:*hub.ebsi.eu/conformance/build-solutions/issue-to-holder-functional-flows + *

+ * Note, that current EBSI Compatibility Tests use the Holder's DID as OIDC client_id in the AuthorizationRequest. + * That is something we need to work with, but I don't think we should model it like that in our realm config. + *

+ * Here the attribute definition that we add by default (when not defined already) + *

+ * { + * "name": "did", + * "displayName": "DID", + * "permissions": { + * "view": ["admin", "user"], + * "edit": ["admin", "user"] + * }, + * "validations": { + * "pattern": { + * "pattern": "^did:.+:.+$", + * "error-message": "Value must start with 'did:scheme:'" + * } + * } + * } * @author Stefan Wiedemann */ public class OID4VCSubjectIdMapper extends OID4VCMapper { @@ -62,8 +98,8 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper { userAttributeConfig.setLabel("User attribute"); userAttributeConfig.setHelpText("The name of the user attribute that maps to the subject id."); userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE); - userAttributeConfig.setOptions(List.of(UserModel.USERNAME, UserModel.EMAIL, UserModel.ID)); - userAttributeConfig.setDefaultValue(UserModel.ID); + userAttributeConfig.setOptions(List.of(UserModel.DID, UserModel.USERNAME, UserModel.EMAIL, UserModel.ID)); + userAttributeConfig.setDefaultValue(UserModel.DID); CONFIG_PROPERTIES.add(userAttributeConfig); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/AbstractCredentialSigner.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/AbstractCredentialSigner.java index 6ddf8c4fda0..d5e8c780d30 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/AbstractCredentialSigner.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/AbstractCredentialSigner.java @@ -42,7 +42,7 @@ public abstract class AbstractCredentialSigner implements CredentialSigner if (credentialBuildConfig.getSigningAlgorithm() == null) { throw new CredentialSignerException(String.format( "A signing algorithm must be configured for credential %s", - credentialBuildConfig.getCredentialId() + credentialBuildConfig.getCredentialConfigId() )); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java index 7df6247290e..b48d37e1158 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java @@ -47,7 +47,7 @@ public class CredentialBuildConfig { private String credentialIssuer; - private String credentialId; + private String credentialConfigId; //-- Proper building configuration fields --// @@ -96,7 +96,7 @@ public class CredentialBuildConfig { String signingAlg = StringUtil.isNotBlank(modelSigningAlg) ? modelSigningAlg : credentialConfiguration.getCredentialSigningAlgValuesSupported().get(0); return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer) - .setCredentialId(credentialConfiguration.getId()) + .setCredentialConfigId(credentialConfiguration.getId()) .setCredentialType(credentialConfiguration.getVct()) .setTokenJwsType(credentialModel.getTokenJwsType()) .setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys()) @@ -115,12 +115,12 @@ public class CredentialBuildConfig { return this; } - public String getCredentialId() { - return credentialId; + public String getCredentialConfigId() { + return credentialConfigId; } - public CredentialBuildConfig setCredentialId(String credentialId) { - this.credentialId = credentialId; + public CredentialBuildConfig setCredentialConfigId(String credentialConfigId) { + this.credentialConfigId = credentialConfigId; return this; } @@ -214,7 +214,7 @@ public class CredentialBuildConfig { return false; } CredentialBuildConfig that = (CredentialBuildConfig) o; - return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType, + return Objects.equals(credentialConfigId, that.credentialConfigId) && Objects.equals(credentialType, that.credentialType) && Objects.equals( tokenJwsType, that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals( @@ -229,7 +229,7 @@ public class CredentialBuildConfig { @Override public int hashCode() { - return Objects.hash(credentialId, + return Objects.hash(credentialConfigId, credentialType, tokenJwsType, hashAlgorithm, diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index 365f1a23ad5..78af8d5cd11 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -512,7 +512,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { String attributeName = metadata.getName(); - if (isBuiltInAttribute(attributeName)) { + if (isBuiltInAttribute(attributeName) && parsedDefaultRawConfig != null) { UPAttribute upAttribute = parsedDefaultRawConfig.getAttribute(attributeName); Map> validations = Optional.ofNullable(upAttribute.getValidations()).orElse(Collections.emptyMap()); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index 962063f6c7c..39049815341 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -70,9 +70,11 @@ import org.keycloak.userprofile.validator.UsernameMutationValidator; import org.keycloak.utils.StringUtil; import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.validators.EmailValidator; +import org.keycloak.validate.validators.PatternValidator; import static java.util.Optional.ofNullable; +import static org.keycloak.common.Profile.Feature.OID4VC_VCI; import static org.keycloak.common.util.ObjectUtil.isBlank; import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY; import static org.keycloak.userprofile.UserProfileContext.ACCOUNT; @@ -81,6 +83,8 @@ import static org.keycloak.userprofile.UserProfileContext.REGISTRATION; import static org.keycloak.userprofile.UserProfileContext.UPDATE_EMAIL; import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE; import static org.keycloak.userprofile.UserProfileContext.USER_API; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN; +import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER; public class DeclarativeUserProfileProviderFactory implements UserProfileProviderFactory, AmphibianProviderFactory { @@ -324,7 +328,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide } // delete cache so new config is parsed and applied next time it is required - // throught #configureUserProfile(metadata, session) + // through #configureUserProfile(metadata, session) if (model != null) { model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY); } @@ -494,7 +498,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide .setRequired(AttributeMetadata.ALWAYS_FALSE); metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE, - DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled) + DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled) .setAttributeDisplayName("${termsAndConditionsUserAttribute}") .setRequired(AttributeMetadata.ALWAYS_FALSE); @@ -521,21 +525,39 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide } private void initDefaultConfiguration(Scope config) { + // The user-defined configuration is always parsed during init and should be avoided as much as possible // If no user-defined configuration is set, the system default configuration must have been set // In Quarkus, the system default configuration is set at build time for optimization purposes - UPConfig defaultConfig = ofNullable(config.get("configFile")) + UPConfig parsedConfig = ofNullable(config.get("configFile")) .map(Paths::get) .map(UPConfigUtils::parseConfig) .orElse(PARSED_DEFAULT_RAW_CONFIG); - if (defaultConfig == null) { - // as a fallback parse the system default config - defaultConfig = UPConfigUtils.parseSystemDefaultConfig(); + // As fallback parse the system default config + if (parsedConfig == null) { + parsedConfig = UPConfigUtils.parseSystemDefaultConfig(); } - PARSED_DEFAULT_RAW_CONFIG = null; - setDefaultConfig(defaultConfig); + // Modify the user profile to use when --features=oid4vc-vci is enabled + if (Profile.isFeatureEnabled(OID4VC_VCI)) { + addUserDidAttribute(parsedConfig); + } + + setDefaultConfig(parsedConfig); + } + + public static void addUserDidAttribute(UPConfig config) { + if (config.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:'"))); + config.addOrReplaceAttribute(attr); + PARSED_DEFAULT_RAW_CONFIG = null; + } } private static Map getEmailAnnotationDecorator(AttributeContext c) { diff --git a/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java index bdd175c360a..cc3bb018210 100644 --- a/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java +++ b/services/src/main/java/org/keycloak/userprofile/config/UPConfigUtils.java @@ -298,18 +298,18 @@ public class UPConfigUtils { if (isBlank(validator)) { errors.add("Validation without validator id is defined for attribute '" + attributeName + "'"); } else { - if(session!=null) { - if(Validators.validator(session, validator) == null) { - errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist"); - } else { - ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig)); - if(!result.isValid()) { - final StringBuilder sb = new StringBuilder(); - result.forEachError(err -> sb.append(err.toString()+", ")); - errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString()); - } - } - } + if(session!=null) { + if(Validators.validator(session, validator) == null) { + errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist"); + } else { + ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig)); + if(!result.isValid()) { + final StringBuilder sb = new StringBuilder(); + result.forEachError(err -> sb.append(err.toString()+", ")); + errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString()); + } + } + } } } @@ -339,7 +339,7 @@ public class UPConfigUtils { try (InputStream is = new FileInputStream(configPath.toFile())) { return parseConfig(is); } catch (IOException ioe) { - throw new RuntimeException("Failed to reaad default user profile configuration: " + configPath, ioe); + throw new RuntimeException("Failed to read default user profile configuration: " + configPath, ioe); } } diff --git a/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json index bde1f5551c1..ae5f62c5c5e 100644 --- a/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json +++ b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json @@ -1,63 +1,108 @@ { - "attributes": [ - { - "name": "username", - "displayName": "${username}", - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "min": 3, "max": 255 }, - "username-prohibited-characters": {}, - "up-username-not-idn-homograph": {} - } - }, - { - "name": "email", - "displayName": "${email}", - "required": {"roles" : ["user"]}, - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "email" : {}, - "length": { "max": 255 } - } - }, - { - "name": "firstName", - "displayName": "${firstName}", - "required": {"roles" : ["user"]}, - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "max": 255 }, - "person-name-prohibited-characters": {} - } - }, - { - "name": "lastName", - "displayName": "${lastName}", - "required": {"roles" : ["user"]}, - "permissions": { - "view": ["admin", "user"], - "edit": ["admin", "user"] - }, - "validations": { - "length": { "max": 255 }, - "person-name-prohibited-characters": {} - } - } - ], - "groups": [ - { - "name": "user-metadata", - "displayHeader": "User metadata", - "displayDescription": "Attributes, which refer to user metadata" - } - ] + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "validations": { + "length": { + "min": 3, + "max": 255 + }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + } + }, + { + "name": "email", + "displayName": "${email}", + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "validations": { + "email": {}, + "length": { + "max": 255 + } + } + }, + { + "name": "firstName", + "displayName": "${firstName}", + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + } + }, + { + "name": "lastName", + "displayName": "${lastName}", + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + } + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } + ] } \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIUserDidAttributeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIUserDidAttributeTest.java new file mode 100644 index 00000000000..420422d8d50 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIUserDidAttributeTest.java @@ -0,0 +1,86 @@ +/* + * 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; + +import java.security.KeyPair; +import java.security.interfaces.ECPublicKey; +import java.util.List; +import java.util.Map; + +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest; + +import org.junit.Before; +import org.junit.Test; + +import static org.keycloak.common.crypto.CryptoConstants.EC_KEY_SECP256R1; +import static org.keycloak.common.util.KeyUtils.generateEcKeyPair; +import static org.keycloak.util.DIDUtils.decodeDidKey; +import static org.keycloak.util.DIDUtils.encodeDidKey; + +import static org.junit.Assert.assertEquals; + +/** + * Tests the User DID Attribute. + * + * @author Thomas Diesler + */ +public class OID4VCIUserDidAttributeTest extends OID4VCIssuerEndpointTest { + + static class TestContext { + String usrDid; + String username; + KeyPair keyPair; + + TestContext(String username) { + this.username = username; + } + } + + TestContext ctx; + + @Before + public void setup() { + super.setup(); + + ctx = new TestContext("alice"); + + // Generate the Holder's KeyPair + ctx.keyPair = generateEcKeyPair(EC_KEY_SECP256R1); + + // Generate the Holder's DID + ECPublicKey publicKey = (ECPublicKey) ctx.keyPair.getPublic(); + ctx.usrDid = encodeDidKey(publicKey); + + // Update the Holder's DID attribute + UserRepresentation userRepresentation = testRealm().users().search(ctx.username).get(0); + userRepresentation.getAttributes().put(UserModel.DID, List.of(ctx.usrDid)); + testRealm().users().get(userRepresentation.getId()).update(userRepresentation); + } + + @Test + public void testDidKeyVerification() throws Exception { + UserRepresentation userRepresentation = testRealm().users().search(ctx.username).get(0); + Map> userAttributes = userRepresentation.getAttributes(); + var wasDid = userAttributes.get(UserModel.DID).get(0); + assertEquals(ctx.usrDid, wasDid); + + ECPublicKey wasPublicKey = decodeDidKey(wasDid); + assertEquals(wasPublicKey, ctx.keyPair.getPublic()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java index 53ad4940c5b..d0958b420b0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java @@ -120,7 +120,7 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest { List realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new) .orElse(new ArrayList<>()); - realmUsers.add(getUserRepresentation("John Doe", List.of(), + realmUsers.add(getUserRepresentation("John Doe", Map.of(), List.of(), Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole")))); testRealm.setUsers(realmUsers); } 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 221e3c94bc0..35ac4171136 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 @@ -96,6 +96,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.RolesRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.testsuite.Assert; @@ -105,6 +106,8 @@ import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse; import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; +import org.keycloak.userprofile.DeclarativeUserProfileProviderFactory; +import org.keycloak.userprofile.config.UPConfigUtils; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.type.TypeReference; @@ -123,6 +126,7 @@ import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENT import static org.keycloak.protocol.oid4vc.model.ProofType.JWT; import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; +import static org.keycloak.userprofile.DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY; import static org.keycloak.util.JsonSerialization.valueAsPrettyString; import static org.junit.Assert.assertEquals; @@ -649,13 +653,16 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { testRealm.setComponents(new MultivaluedHashMap<>()); } + // Add key providers testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider()); - testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100)); testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101)); + // Add Did attribute to the user profile + testRealm.getComponents().add("org.keycloak.userprofile.UserProfileProvider", getUserProfileProvider()); + // Find existing client representation Map realmClients = testRealm.getClients().stream() .collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity())); @@ -678,11 +685,26 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { Map> clientRoles = Map.of(clientId, List.of("testRole")); List realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>()); - realmUsers.add(getUserRepresentation("John Doe", List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles)); - realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of())); + realmUsers.add(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles)); + realmUsers.add(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of())); testRealm.setUsers(realmUsers); } + private ComponentExportRepresentation getUserProfileProvider() { + + // Add the User DID attribute, with the same logic as in DeclarativeUserProfileProviderFactory + // + UPConfig profileConfig = UPConfigUtils.parseSystemDefaultConfig(); + DeclarativeUserProfileProviderFactory.addUserDidAttribute(profileConfig); + + ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation(); + componentExportRepresentation.setProviderId("declarative-user-profile"); + componentExportRepresentation.setConfig(new MultivaluedHashMap<>( + Map.of(UP_COMPONENT_CONFIG_KEY, List.of(JsonSerialization.valueAsString(profileConfig))))); + + return componentExportRepresentation; + } + protected void withCausePropagation(Runnable r) throws Throwable { try { r.run(); 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 4f58e40d61b..fe95a10f44f 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 @@ -67,6 +67,8 @@ import org.apache.http.HttpStatus; import org.junit.Assert; import org.junit.Test; +import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -409,46 +411,36 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest { assertNotNull("The sd-jwt-credential can optionally provide a claims claim.", jwtVcClaims); - assertEquals(4, jwtVcClaims.size()); + assertEquals(5, jwtVcClaims.size()); { Claim claim = jwtVcClaims.get(0); - assertEquals("The sd-jwt-credential claim email is present.", - "email", - claim.getPath().get(0)); - assertFalse("The sd-jwt-credential claim email is not mandatory.", - claim.isMandatory()); - assertNull("The sd-jwt-credential claim email has no display configured", - claim.getDisplay()); + assertEquals("id claim is present", CLAIM_NAME_SUBJECT_ID, claim.getPath().get(0)); + assertFalse("id claim not mandatory.", claim.isMandatory()); + assertNull("id has no display value", claim.getDisplay()); } { Claim claim = jwtVcClaims.get(1); - assertEquals("The sd-jwt-credential claim firstName is present.", - "firstName", - claim.getPath().get(0)); - assertFalse("The sd-jwt-credential claim firstName is not mandatory.", - claim.isMandatory()); - assertNull("The sd-jwt-credential claim firstName has no display configured", - claim.getDisplay()); + assertEquals("email claim is present", "email", claim.getPath().get(0)); + assertFalse("email claim not mandatory.", claim.isMandatory()); + assertNull("email has no display value", claim.getDisplay()); } { Claim claim = jwtVcClaims.get(2); - assertEquals("The sd-jwt-credential claim lastName is present.", - "lastName", - claim.getPath().get(0)); - assertFalse("The sd-jwt-credential claim lastName is not mandatory.", - claim.isMandatory()); - assertNull("The sd-jwt-credential claim lastName has no display configured", - claim.getDisplay()); + assertEquals("firstName claim is present", "firstName", claim.getPath().get(0)); + assertFalse("firstName claim not mandatory.", claim.isMandatory()); + assertNull("firstName has no display value", claim.getDisplay()); } { Claim claim = jwtVcClaims.get(3); - assertEquals("The sd-jwt-credential claim scope-name is present.", - "scope-name", - claim.getPath().get(0)); - assertFalse("The sd-jwt-credential claim scope-name is not mandatory.", - claim.isMandatory()); - assertNull("The sd-jwt-credential claim scope-name has no display configured", - claim.getDisplay()); + assertEquals("lastName claim is present", "lastName", claim.getPath().get(0)); + assertFalse("lastName claim not mandatory.", claim.isMandatory()); + assertNull("lastName has no display value", claim.getDisplay()); + } + { + Claim claim = jwtVcClaims.get(4); + assertEquals("scope-name claim is present", "scope-name", claim.getPath().get(0)); + assertFalse("scope-name claim not mandatory.", claim.isMandatory()); + assertNull("scope-name has no display value", claim.getDisplay()); } assertEquals("The sd-jwt-credential should offer vct", 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 7652224d792..5a5ef69c551 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 @@ -18,6 +18,7 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -65,6 +66,7 @@ import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor; @@ -89,9 +91,11 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ComponentExportRepresentation; 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.services.resources.RealmsResource; +import org.keycloak.testsuite.AbstractAdminTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.util.AdminClientUtil; @@ -109,6 +113,7 @@ import org.junit.Assert; import org.junit.BeforeClass; import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; +import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest.TIME_PROVIDER; import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer; import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getJtiGeneratedIdMapper; @@ -145,6 +150,15 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser()); } + @Override + public void addTestRealms(List testRealms) { + log.debug("Adding test realm for import from testrealm.json"); + InputStream inputStream = getClass().getResourceAsStream("/testrealm.json"); + RealmRepresentation testRealm = AbstractAdminTest.loadJson(inputStream, RealmRepresentation.class); + testRealms.add(testRealm); + configureTestRealm(testRealm); + } + protected static CredentialSubject getCredentialSubject(Map claims) { CredentialSubject credentialSubject = new CredentialSubject(); claims.forEach(credentialSubject::setClaims); @@ -336,6 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { public List getProtocolMappers(String scopeName) { return List.of( + getSubjectIdMapper(CLAIM_NAME_SUBJECT_ID, UserModel.DID), getUserAttributeMapper("email", "email"), getUserAttributeMapper("firstName", "firstName"), getUserAttributeMapper("lastName", "lastName"), @@ -409,6 +424,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { public static UserRepresentation getUserRepresentation( String fullName, + Map attributes, List realmRoles, Map> clientRoles ) { @@ -428,6 +444,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { .role("account", "manage-account") .role("account", "view-profile"); + attributes.forEach(userBuilder::addAttribute); + // 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. @@ -534,6 +552,18 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return protocolMapperRepresentation; } + protected ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName) { + ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); + protocolMapperRepresentation.setName(attributeName + "-mapper"); + protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + protocolMapperRepresentation.setId(UUID.randomUUID().toString()); + protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper"); + protocolMapperRepresentation.setConfig(Map.of( + "claim.name", subjectProperty, + "userAttribute", attributeName)); + return protocolMapperRepresentation; + } + protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) { ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json index f9099ff212a..0229697405c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json @@ -14,7 +14,7 @@ "protocolMapper": "oid4vc-subject-id-mapper", "config": { "claim.name": "id", - "userAttribute": "id", + "userAttribute": "did", "vc.mandatory": "false" } }, diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index 6421740ab50..bc3584625b6 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -54,6 +54,7 @@ currentPassword=Current Password passwordConfirm=Confirmation passwordNew=New Password username=Username +did=Identity (DID) address=Address street=Street locality=City or Locality