[OID4VCI] Add support for user did as subject id (#45008)

closes #45006


Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler 2026-01-30 17:29:47 +01:00 committed by GitHub
parent 0433b0017d
commit c08ed20f78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 836 additions and 130 deletions

View file

@ -29,6 +29,7 @@ import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException; import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.crypto.CryptoIntegration;
@ -55,6 +56,15 @@ public final class DerUtils {
return decodePrivateKey(keyBytes); 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 { public static PublicKey decodePublicKey(byte[] der) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
return decodePublicKey(der, "RSA"); return decodePublicKey(der, "RSA");
} }

View file

@ -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.
* <p>
* Note that this is not the same base58 as used by Flickr, which you may find referenced around the Internet.
* <p>
* 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.
* <p>
* Satoshi explains: why base-58 instead of standard base-64 encoding?
* <ul>
* <li>Don't want 0OIl characters that look the same in some fonts and
* could be used to create visually identical looking account numbers.</li>
* <li>A string with non-alphanumeric characters is not as easily accepted as an account number.</li>
* <li>E-mail usually won't line-break if there's no punctuation to break at.</li>
* <li>Doubleclicking selects the whole number as one word if it's all alphanumeric.</li>
* </ul>
* <p>
* However, note that the encoding/decoding runs in O(n&sup2;) time, so it is not useful for large data.
* <p>
* 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.
* <p>
* 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;
}
}

View file

@ -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).
* <p>
* Provides:
* - EC did:key:z... encoding (multibase + multicodec + base58btc)
* - did:key EC public key decoding
* - multicodec varint encode/decode
* - EC point normalization
* <p>
*
* @author <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
*/
public final class DIDUtils {
private DIDUtils() {
}
/**
* Multicodec identifier for P-256 public keys.
* See: https://github.com/multiformats/multicodec/
* <p>
* 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<String, Object> 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;
}
}

View file

@ -38,6 +38,7 @@ public interface UserModel extends RoleMapperModel {
String EMAIL = "email"; String EMAIL = "email";
String EMAIL_PENDING = "kc.email.pending"; String EMAIL_PENDING = "kc.email.pending";
String EMAIL_VERIFIED = "emailVerified"; String EMAIL_VERIFIED = "emailVerified";
String DID = "did";
String LOCALE = "locale"; String LOCALE = "locale";
String ENABLED = "enabled"; String ENABLED = "enabled";
String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias"; String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias";

View file

@ -94,7 +94,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
@Override @Override
public void init(Config.Scope config) { 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(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false));
builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", 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)); builtins.put(FIRST_NAME_MAPPER, OID4VCUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false));

View file

@ -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 * Sets an ID for the credential subject, either from User ID or by attribute mapping
* * <p/>
* 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.
* <p/>
* 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
* <p/>
* In future, it may be possible that ...
* <p/>
* * 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
* <p/>
* 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
* <p/>
* 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.
* <p/>
* Here the attribute definition that we add by default (when not defined already)
* <p/>
* {
* "name": "did",
* "displayName": "DID",
* "permissions": {
* "view": ["admin", "user"],
* "edit": ["admin", "user"]
* },
* "validations": {
* "pattern": {
* "pattern": "^did:.+:.+$",
* "error-message": "Value must start with 'did:scheme:'"
* }
* }
* }
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a> * @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/ */
public class OID4VCSubjectIdMapper extends OID4VCMapper { public class OID4VCSubjectIdMapper extends OID4VCMapper {
@ -62,8 +98,8 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
userAttributeConfig.setLabel("User attribute"); userAttributeConfig.setLabel("User attribute");
userAttributeConfig.setHelpText("The name of the user attribute that maps to the subject id."); userAttributeConfig.setHelpText("The name of the user attribute that maps to the subject id.");
userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE); userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE);
userAttributeConfig.setOptions(List.of(UserModel.USERNAME, UserModel.EMAIL, UserModel.ID)); userAttributeConfig.setOptions(List.of(UserModel.DID, UserModel.USERNAME, UserModel.EMAIL, UserModel.ID));
userAttributeConfig.setDefaultValue(UserModel.ID); userAttributeConfig.setDefaultValue(UserModel.DID);
CONFIG_PROPERTIES.add(userAttributeConfig); CONFIG_PROPERTIES.add(userAttributeConfig);
} }

View file

@ -42,7 +42,7 @@ public abstract class AbstractCredentialSigner<T> implements CredentialSigner<T>
if (credentialBuildConfig.getSigningAlgorithm() == null) { if (credentialBuildConfig.getSigningAlgorithm() == null) {
throw new CredentialSignerException(String.format( throw new CredentialSignerException(String.format(
"A signing algorithm must be configured for credential %s", "A signing algorithm must be configured for credential %s",
credentialBuildConfig.getCredentialId() credentialBuildConfig.getCredentialConfigId()
)); ));
} }

View file

@ -47,7 +47,7 @@ public class CredentialBuildConfig {
private String credentialIssuer; private String credentialIssuer;
private String credentialId; private String credentialConfigId;
//-- Proper building configuration fields --// //-- Proper building configuration fields --//
@ -96,7 +96,7 @@ public class CredentialBuildConfig {
String signingAlg = StringUtil.isNotBlank(modelSigningAlg) ? modelSigningAlg : credentialConfiguration.getCredentialSigningAlgValuesSupported().get(0); String signingAlg = StringUtil.isNotBlank(modelSigningAlg) ? modelSigningAlg : credentialConfiguration.getCredentialSigningAlgValuesSupported().get(0);
return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer) return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer)
.setCredentialId(credentialConfiguration.getId()) .setCredentialConfigId(credentialConfiguration.getId())
.setCredentialType(credentialConfiguration.getVct()) .setCredentialType(credentialConfiguration.getVct())
.setTokenJwsType(credentialModel.getTokenJwsType()) .setTokenJwsType(credentialModel.getTokenJwsType())
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys()) .setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
@ -115,12 +115,12 @@ public class CredentialBuildConfig {
return this; return this;
} }
public String getCredentialId() { public String getCredentialConfigId() {
return credentialId; return credentialConfigId;
} }
public CredentialBuildConfig setCredentialId(String credentialId) { public CredentialBuildConfig setCredentialConfigId(String credentialConfigId) {
this.credentialId = credentialId; this.credentialConfigId = credentialConfigId;
return this; return this;
} }
@ -214,7 +214,7 @@ public class CredentialBuildConfig {
return false; return false;
} }
CredentialBuildConfig that = (CredentialBuildConfig) o; 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( that.credentialType) && Objects.equals(
tokenJwsType, tokenJwsType,
that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals( that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(
@ -229,7 +229,7 @@ public class CredentialBuildConfig {
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(credentialId, return Objects.hash(credentialConfigId,
credentialType, credentialType,
tokenJwsType, tokenJwsType,
hashAlgorithm, hashAlgorithm,

View file

@ -512,7 +512,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
String attributeName = metadata.getName(); String attributeName = metadata.getName();
if (isBuiltInAttribute(attributeName)) { if (isBuiltInAttribute(attributeName) && parsedDefaultRawConfig != null) {
UPAttribute upAttribute = parsedDefaultRawConfig.getAttribute(attributeName); UPAttribute upAttribute = parsedDefaultRawConfig.getAttribute(attributeName);
Map<String, Map<String, Object>> validations = Optional.ofNullable(upAttribute.getValidations()).orElse(Collections.emptyMap()); Map<String, Map<String, Object>> validations = Optional.ofNullable(upAttribute.getValidations()).orElse(Collections.emptyMap());

View file

@ -70,9 +70,11 @@ import org.keycloak.userprofile.validator.UsernameMutationValidator;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.EmailValidator; import org.keycloak.validate.validators.EmailValidator;
import org.keycloak.validate.validators.PatternValidator;
import static java.util.Optional.ofNullable; 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.common.util.ObjectUtil.isBlank;
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY; import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT; 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_EMAIL;
import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE; import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE;
import static org.keycloak.userprofile.UserProfileContext.USER_API; 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<UserProfileProvider> { public class DeclarativeUserProfileProviderFactory implements UserProfileProviderFactory, AmphibianProviderFactory<UserProfileProvider> {
@ -324,7 +328,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
} }
// delete cache so new config is parsed and applied next time it is required // 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) { if (model != null) {
model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY); model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY);
} }
@ -494,7 +498,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
.setRequired(AttributeMetadata.ALWAYS_FALSE); .setRequired(AttributeMetadata.ALWAYS_FALSE);
metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE, metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE,
DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled) DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled)
.setAttributeDisplayName("${termsAndConditionsUserAttribute}") .setAttributeDisplayName("${termsAndConditionsUserAttribute}")
.setRequired(AttributeMetadata.ALWAYS_FALSE); .setRequired(AttributeMetadata.ALWAYS_FALSE);
@ -521,21 +525,39 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
} }
private void initDefaultConfiguration(Scope config) { private void initDefaultConfiguration(Scope config) {
// The user-defined configuration is always parsed during init and should be avoided as much as possible // 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 // 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 // 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(Paths::get)
.map(UPConfigUtils::parseConfig) .map(UPConfigUtils::parseConfig)
.orElse(PARSED_DEFAULT_RAW_CONFIG); .orElse(PARSED_DEFAULT_RAW_CONFIG);
if (defaultConfig == null) { // As fallback parse the system default config
// as a fallback parse the system default config if (parsedConfig == null) {
defaultConfig = UPConfigUtils.parseSystemDefaultConfig(); parsedConfig = UPConfigUtils.parseSystemDefaultConfig();
} }
PARSED_DEFAULT_RAW_CONFIG = null; // Modify the user profile to use when --features=oid4vc-vci is enabled
setDefaultConfig(defaultConfig); 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<String, Object> getEmailAnnotationDecorator(AttributeContext c) { private static Map<String, Object> getEmailAnnotationDecorator(AttributeContext c) {

View file

@ -298,18 +298,18 @@ public class UPConfigUtils {
if (isBlank(validator)) { if (isBlank(validator)) {
errors.add("Validation without validator id is defined for attribute '" + attributeName + "'"); errors.add("Validation without validator id is defined for attribute '" + attributeName + "'");
} else { } else {
if(session!=null) { if(session!=null) {
if(Validators.validator(session, validator) == null) { if(Validators.validator(session, validator) == null) {
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist"); errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist");
} else { } else {
ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig)); ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig));
if(!result.isValid()) { if(!result.isValid()) {
final StringBuilder sb = new StringBuilder(); final StringBuilder sb = new StringBuilder();
result.forEachError(err -> sb.append(err.toString()+", ")); result.forEachError(err -> sb.append(err.toString()+", "));
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.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())) { try (InputStream is = new FileInputStream(configPath.toFile())) {
return parseConfig(is); return parseConfig(is);
} catch (IOException ioe) { } 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);
} }
} }

View file

@ -1,63 +1,108 @@
{ {
"attributes": [ "attributes": [
{ {
"name": "username", "name": "username",
"displayName": "${username}", "displayName": "${username}",
"permissions": { "permissions": {
"view": ["admin", "user"], "view": [
"edit": ["admin", "user"] "admin",
}, "user"
"validations": { ],
"length": { "min": 3, "max": 255 }, "edit": [
"username-prohibited-characters": {}, "admin",
"up-username-not-idn-homograph": {} "user"
} ]
}, },
{ "validations": {
"name": "email", "length": {
"displayName": "${email}", "min": 3,
"required": {"roles" : ["user"]}, "max": 255
"permissions": { },
"view": ["admin", "user"], "username-prohibited-characters": {},
"edit": ["admin", "user"] "up-username-not-idn-homograph": {}
}, }
"validations": { },
"email" : {}, {
"length": { "max": 255 } "name": "email",
} "displayName": "${email}",
}, "required": {
{ "roles": [
"name": "firstName", "user"
"displayName": "${firstName}", ]
"required": {"roles" : ["user"]}, },
"permissions": { "permissions": {
"view": ["admin", "user"], "view": [
"edit": ["admin", "user"] "admin",
}, "user"
"validations": { ],
"length": { "max": 255 }, "edit": [
"person-name-prohibited-characters": {} "admin",
} "user"
}, ]
{ },
"name": "lastName", "validations": {
"displayName": "${lastName}", "email": {},
"required": {"roles" : ["user"]}, "length": {
"permissions": { "max": 255
"view": ["admin", "user"], }
"edit": ["admin", "user"] }
}, },
"validations": { {
"length": { "max": 255 }, "name": "firstName",
"person-name-prohibited-characters": {} "displayName": "${firstName}",
} "required": {
} "roles": [
], "user"
"groups": [ ]
{ },
"name": "user-metadata", "permissions": {
"displayHeader": "User metadata", "view": [
"displayDescription": "Attributes, which refer to user metadata" "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"
}
]
} }

View file

@ -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 <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
*/
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<String, List<String>> userAttributes = userRepresentation.getAttributes();
var wasDid = userAttributes.get(UserModel.DID).get(0);
assertEquals(ctx.usrDid, wasDid);
ECPublicKey wasPublicKey = decodeDidKey(wasDid);
assertEquals(wasPublicKey, ctx.keyPair.getPublic());
}
}

View file

@ -120,7 +120,7 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest {
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new) List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>()); .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")))); Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole"))));
testRealm.setUsers(realmUsers); testRealm.setUsers(realmUsers);
} }

View file

@ -96,6 +96,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.RolesRepresentation; import org.keycloak.representations.idm.RolesRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.testsuite.Assert; 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.OpenIDProviderConfigurationResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse; import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse; 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 org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference; 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.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; 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.keycloak.util.JsonSerialization.valueAsPrettyString;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -649,13 +653,16 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
testRealm.setComponents(new MultivaluedHashMap<>()); testRealm.setComponents(new MultivaluedHashMap<>());
} }
// Add key providers
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider()); testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider());
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100)); getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100));
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101)); 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 // Find existing client representation
Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream() Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
.collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity())); .collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity()));
@ -678,11 +685,26 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole")); Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole"));
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>()); List<UserRepresentation> 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("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles));
realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of())); realmUsers.add(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()));
testRealm.setUsers(realmUsers); 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 { protected void withCausePropagation(Runnable r) throws Throwable {
try { try {
r.run(); r.run();

View file

@ -67,6 +67,8 @@ import org.apache.http.HttpStatus;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; 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.", assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
jwtVcClaims); jwtVcClaims);
assertEquals(4, jwtVcClaims.size()); assertEquals(5, jwtVcClaims.size());
{ {
Claim claim = jwtVcClaims.get(0); Claim claim = jwtVcClaims.get(0);
assertEquals("The sd-jwt-credential claim email is present.", assertEquals("id claim is present", CLAIM_NAME_SUBJECT_ID, claim.getPath().get(0));
"email", assertFalse("id claim not mandatory.", claim.isMandatory());
claim.getPath().get(0)); assertNull("id has no display value", claim.getDisplay());
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());
} }
{ {
Claim claim = jwtVcClaims.get(1); Claim claim = jwtVcClaims.get(1);
assertEquals("The sd-jwt-credential claim firstName is present.", assertEquals("email claim is present", "email", claim.getPath().get(0));
"firstName", assertFalse("email claim not mandatory.", claim.isMandatory());
claim.getPath().get(0)); assertNull("email has no display value", claim.getDisplay());
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());
} }
{ {
Claim claim = jwtVcClaims.get(2); Claim claim = jwtVcClaims.get(2);
assertEquals("The sd-jwt-credential claim lastName is present.", assertEquals("firstName claim is present", "firstName", claim.getPath().get(0));
"lastName", assertFalse("firstName claim not mandatory.", claim.isMandatory());
claim.getPath().get(0)); assertNull("firstName has no display value", claim.getDisplay());
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());
} }
{ {
Claim claim = jwtVcClaims.get(3); Claim claim = jwtVcClaims.get(3);
assertEquals("The sd-jwt-credential claim scope-name is present.", assertEquals("lastName claim is present", "lastName", claim.getPath().get(0));
"scope-name", assertFalse("lastName claim not mandatory.", claim.isMandatory());
claim.getPath().get(0)); assertNull("lastName has no display value", claim.getDisplay());
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 claim = jwtVcClaims.get(4);
claim.getDisplay()); 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", assertEquals("The sd-jwt-credential should offer vct",

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.oid4vc.issuance.signing; package org.keycloak.testsuite.oid4vc.issuance.signing;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; 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.JWSBuilder;
import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor; 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.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation; import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractAdminTest;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.AdminClientUtil;
@ -109,6 +113,7 @@ import org.junit.Assert;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL; 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.OID4VCIssuerEndpointTest.TIME_PROVIDER;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer; import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getJtiGeneratedIdMapper; 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()); AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
} }
@Override
public void addTestRealms(List<RealmRepresentation> 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<String, Object> claims) { protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
CredentialSubject credentialSubject = new CredentialSubject(); CredentialSubject credentialSubject = new CredentialSubject();
claims.forEach(credentialSubject::setClaims); claims.forEach(credentialSubject::setClaims);
@ -336,6 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) { public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
return List.of( return List.of(
getSubjectIdMapper(CLAIM_NAME_SUBJECT_ID, UserModel.DID),
getUserAttributeMapper("email", "email"), getUserAttributeMapper("email", "email"),
getUserAttributeMapper("firstName", "firstName"), getUserAttributeMapper("firstName", "firstName"),
getUserAttributeMapper("lastName", "lastName"), getUserAttributeMapper("lastName", "lastName"),
@ -409,6 +424,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public static UserRepresentation getUserRepresentation( public static UserRepresentation getUserRepresentation(
String fullName, String fullName,
Map<String, String> attributes,
List<String> realmRoles, List<String> realmRoles,
Map<String, List<String>> clientRoles Map<String, List<String>> clientRoles
) { ) {
@ -428,6 +444,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
.role("account", "manage-account") .role("account", "manage-account")
.role("account", "view-profile"); .role("account", "view-profile");
attributes.forEach(userBuilder::addAttribute);
// When Keycloak issues a token for a user and client: // 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. // 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; 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) { protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation(); ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper"); protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");

View file

@ -14,7 +14,7 @@
"protocolMapper": "oid4vc-subject-id-mapper", "protocolMapper": "oid4vc-subject-id-mapper",
"config": { "config": {
"claim.name": "id", "claim.name": "id",
"userAttribute": "id", "userAttribute": "did",
"vc.mandatory": "false" "vc.mandatory": "false"
} }
}, },

View file

@ -54,6 +54,7 @@ currentPassword=Current Password
passwordConfirm=Confirmation passwordConfirm=Confirmation
passwordNew=New Password passwordNew=New Password
username=Username username=Username
did=Identity (DID)
address=Address address=Address
street=Street street=Street
locality=City or Locality locality=City or Locality