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?
+ *
+ * - Don't want 0OIl characters that look the same in some fonts and
+ * could be used to create visually identical looking account numbers.
+ * - A string with non-alphanumeric characters is not as easily accepted as an account number.
+ * - E-mail usually won't line-break if there's no punctuation to break at.
+ * - Doubleclicking selects the whole number as one word if it's all alphanumeric.
+ *
+ *
+ * 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