mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
[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:
parent
0433b0017d
commit
c08ed20f78
19 changed files with 836 additions and 130 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
162
core/src/main/java/org/keycloak/util/Base58.java
Normal file
162
core/src/main/java/org/keycloak/util/Base58.java
Normal 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²) 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;
|
||||
}
|
||||
}
|
||||
299
core/src/main/java/org/keycloak/util/DIDUtils.java
Normal file
299
core/src/main/java/org/keycloak/util/DIDUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"protocolMapper": "oid4vc-subject-id-mapper",
|
||||
"config": {
|
||||
"claim.name": "id",
|
||||
"userAttribute": "id",
|
||||
"userAttribute": "did",
|
||||
"vc.mandatory": "false"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue