[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.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");
}

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_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";

View file

@ -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));

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
*
* <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>
*/
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);
}

View file

@ -42,7 +42,7 @@ public abstract class AbstractCredentialSigner<T> implements CredentialSigner<T>
if (credentialBuildConfig.getSigningAlgorithm() == null) {
throw new CredentialSignerException(String.format(
"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 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,

View file

@ -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<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.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<UserProfileProvider> {
@ -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<String, Object> getEmailAnnotationDecorator(AttributeContext c) {

View file

@ -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);
}
}

View file

@ -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"
}
]
}

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)
.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);
}

View file

@ -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<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
.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"));
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("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();

View file

@ -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",

View file

@ -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<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) {
CredentialSubject credentialSubject = new CredentialSubject();
claims.forEach(credentialSubject::setClaims);
@ -336,6 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public List<ProtocolMapperRepresentation> 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<String, String> attributes,
List<String> realmRoles,
Map<String, List<String>> 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");

View file

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

View file

@ -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