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.InvalidKeySpecException;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
import org.keycloak.common.crypto.CryptoIntegration;
|
import org.keycloak.common.crypto.CryptoIntegration;
|
||||||
|
|
||||||
|
|
@ -55,6 +56,15 @@ public final class DerUtils {
|
||||||
return decodePrivateKey(keyBytes);
|
return decodePrivateKey(keyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PublicKey decodePublicKey(String encoded) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
|
||||||
|
return decodePublicKey(encoded, "RSA");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublicKey decodePublicKey(String encoded, String type) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
|
||||||
|
byte[] der = Base64.getDecoder().decode(encoded);
|
||||||
|
return decodePublicKey(der, type);
|
||||||
|
}
|
||||||
|
|
||||||
public static PublicKey decodePublicKey(byte[] der) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
|
public static PublicKey decodePublicKey(byte[] der) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
|
||||||
return decodePublicKey(der, "RSA");
|
return decodePublicKey(der, "RSA");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 = "email";
|
||||||
String EMAIL_PENDING = "kc.email.pending";
|
String EMAIL_PENDING = "kc.email.pending";
|
||||||
String EMAIL_VERIFIED = "emailVerified";
|
String EMAIL_VERIFIED = "emailVerified";
|
||||||
|
String DID = "did";
|
||||||
String LOCALE = "locale";
|
String LOCALE = "locale";
|
||||||
String ENABLED = "enabled";
|
String ENABLED = "enabled";
|
||||||
String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias";
|
String IDP_ALIAS = "keycloak.session.realm.users.query.idp_alias";
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init(Config.Scope config) {
|
public void init(Config.Scope config) {
|
||||||
builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create(SUBJECT_ID_MAPPER, CLAIM_NAME_SUBJECT_ID, UserModel.ID));
|
builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create(SUBJECT_ID_MAPPER, CLAIM_NAME_SUBJECT_ID, UserModel.DID));
|
||||||
builtins.put(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false));
|
builtins.put(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false));
|
||||||
builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false));
|
builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false));
|
||||||
builtins.put(FIRST_NAME_MAPPER, OID4VCUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false));
|
builtins.put(FIRST_NAME_MAPPER, OID4VCUserAttributeMapper.create(FIRST_NAME_MAPPER, "firstName", "firstName", false));
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,43 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets an ID for the credential subject, either from User ID or by attribute mapping
|
* Sets an ID for the credential subject, either from User ID or by attribute mapping
|
||||||
*
|
* <p/>
|
||||||
|
* A Verifiable Credential (VC) is often bound to a Holder's Digital Identity (DID).
|
||||||
|
* The Holder's DID is in turn bound to Key Material that the Issuer + Verifier can discover from the DID Document.
|
||||||
|
* In case of "did:key:..." the Public Key is already encoded in the DID.
|
||||||
|
* <p/>
|
||||||
|
* Here, we make sure that the default UserProfile has a 'did' attribute when --feature=oid4vc-vci is enabled.
|
||||||
|
* Conceptually, it is however debatable whether the Issuer should know even one of the Holder's DIDs
|
||||||
|
* <p/>
|
||||||
|
* In future, it may be possible that ...
|
||||||
|
* <p/>
|
||||||
|
* * The Holder communicates the DID at the time of Authorization
|
||||||
|
* * The Issuer then verifies Holder possession of the associated Key Material
|
||||||
|
* * The Issuer then somehow associates the AuthorizationRequest with a registered User
|
||||||
|
* * The VC is then issued to the Holder without the Issuer needing to remember that Holder DID
|
||||||
|
* <p/>
|
||||||
|
* This kind of Authorization protocol is for example required by EBSI, which we aim to become compatible with.
|
||||||
|
* https:*hub.ebsi.eu/conformance/build-solutions/issue-to-holder-functional-flows
|
||||||
|
* <p/>
|
||||||
|
* Note, that current EBSI Compatibility Tests use the Holder's DID as OIDC client_id in the AuthorizationRequest.
|
||||||
|
* That is something we need to work with, but I don't think we should model it like that in our realm config.
|
||||||
|
* <p/>
|
||||||
|
* Here the attribute definition that we add by default (when not defined already)
|
||||||
|
* <p/>
|
||||||
|
* {
|
||||||
|
* "name": "did",
|
||||||
|
* "displayName": "DID",
|
||||||
|
* "permissions": {
|
||||||
|
* "view": ["admin", "user"],
|
||||||
|
* "edit": ["admin", "user"]
|
||||||
|
* },
|
||||||
|
* "validations": {
|
||||||
|
* "pattern": {
|
||||||
|
* "pattern": "^did:.+:.+$",
|
||||||
|
* "error-message": "Value must start with 'did:scheme:'"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||||
*/
|
*/
|
||||||
public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||||
|
|
@ -62,8 +98,8 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||||
userAttributeConfig.setLabel("User attribute");
|
userAttributeConfig.setLabel("User attribute");
|
||||||
userAttributeConfig.setHelpText("The name of the user attribute that maps to the subject id.");
|
userAttributeConfig.setHelpText("The name of the user attribute that maps to the subject id.");
|
||||||
userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE);
|
userAttributeConfig.setType(ProviderConfigProperty.LIST_TYPE);
|
||||||
userAttributeConfig.setOptions(List.of(UserModel.USERNAME, UserModel.EMAIL, UserModel.ID));
|
userAttributeConfig.setOptions(List.of(UserModel.DID, UserModel.USERNAME, UserModel.EMAIL, UserModel.ID));
|
||||||
userAttributeConfig.setDefaultValue(UserModel.ID);
|
userAttributeConfig.setDefaultValue(UserModel.DID);
|
||||||
CONFIG_PROPERTIES.add(userAttributeConfig);
|
CONFIG_PROPERTIES.add(userAttributeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public abstract class AbstractCredentialSigner<T> implements CredentialSigner<T>
|
||||||
if (credentialBuildConfig.getSigningAlgorithm() == null) {
|
if (credentialBuildConfig.getSigningAlgorithm() == null) {
|
||||||
throw new CredentialSignerException(String.format(
|
throw new CredentialSignerException(String.format(
|
||||||
"A signing algorithm must be configured for credential %s",
|
"A signing algorithm must be configured for credential %s",
|
||||||
credentialBuildConfig.getCredentialId()
|
credentialBuildConfig.getCredentialConfigId()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ public class CredentialBuildConfig {
|
||||||
|
|
||||||
private String credentialIssuer;
|
private String credentialIssuer;
|
||||||
|
|
||||||
private String credentialId;
|
private String credentialConfigId;
|
||||||
|
|
||||||
//-- Proper building configuration fields --//
|
//-- Proper building configuration fields --//
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ public class CredentialBuildConfig {
|
||||||
String signingAlg = StringUtil.isNotBlank(modelSigningAlg) ? modelSigningAlg : credentialConfiguration.getCredentialSigningAlgValuesSupported().get(0);
|
String signingAlg = StringUtil.isNotBlank(modelSigningAlg) ? modelSigningAlg : credentialConfiguration.getCredentialSigningAlgValuesSupported().get(0);
|
||||||
|
|
||||||
return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer)
|
return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer)
|
||||||
.setCredentialId(credentialConfiguration.getId())
|
.setCredentialConfigId(credentialConfiguration.getId())
|
||||||
.setCredentialType(credentialConfiguration.getVct())
|
.setCredentialType(credentialConfiguration.getVct())
|
||||||
.setTokenJwsType(credentialModel.getTokenJwsType())
|
.setTokenJwsType(credentialModel.getTokenJwsType())
|
||||||
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
|
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
|
||||||
|
|
@ -115,12 +115,12 @@ public class CredentialBuildConfig {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCredentialId() {
|
public String getCredentialConfigId() {
|
||||||
return credentialId;
|
return credentialConfigId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CredentialBuildConfig setCredentialId(String credentialId) {
|
public CredentialBuildConfig setCredentialConfigId(String credentialConfigId) {
|
||||||
this.credentialId = credentialId;
|
this.credentialConfigId = credentialConfigId;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,7 +214,7 @@ public class CredentialBuildConfig {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
CredentialBuildConfig that = (CredentialBuildConfig) o;
|
CredentialBuildConfig that = (CredentialBuildConfig) o;
|
||||||
return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType,
|
return Objects.equals(credentialConfigId, that.credentialConfigId) && Objects.equals(credentialType,
|
||||||
that.credentialType) && Objects.equals(
|
that.credentialType) && Objects.equals(
|
||||||
tokenJwsType,
|
tokenJwsType,
|
||||||
that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(
|
that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(
|
||||||
|
|
@ -229,7 +229,7 @@ public class CredentialBuildConfig {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return Objects.hash(credentialId,
|
return Objects.hash(credentialConfigId,
|
||||||
credentialType,
|
credentialType,
|
||||||
tokenJwsType,
|
tokenJwsType,
|
||||||
hashAlgorithm,
|
hashAlgorithm,
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
|
||||||
|
|
||||||
String attributeName = metadata.getName();
|
String attributeName = metadata.getName();
|
||||||
|
|
||||||
if (isBuiltInAttribute(attributeName)) {
|
if (isBuiltInAttribute(attributeName) && parsedDefaultRawConfig != null) {
|
||||||
UPAttribute upAttribute = parsedDefaultRawConfig.getAttribute(attributeName);
|
UPAttribute upAttribute = parsedDefaultRawConfig.getAttribute(attributeName);
|
||||||
Map<String, Map<String, Object>> validations = Optional.ofNullable(upAttribute.getValidations()).orElse(Collections.emptyMap());
|
Map<String, Map<String, Object>> validations = Optional.ofNullable(upAttribute.getValidations()).orElse(Collections.emptyMap());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ import org.keycloak.userprofile.validator.UsernameMutationValidator;
|
||||||
import org.keycloak.utils.StringUtil;
|
import org.keycloak.utils.StringUtil;
|
||||||
import org.keycloak.validate.ValidatorConfig;
|
import org.keycloak.validate.ValidatorConfig;
|
||||||
import org.keycloak.validate.validators.EmailValidator;
|
import org.keycloak.validate.validators.EmailValidator;
|
||||||
|
import org.keycloak.validate.validators.PatternValidator;
|
||||||
|
|
||||||
import static java.util.Optional.ofNullable;
|
import static java.util.Optional.ofNullable;
|
||||||
|
|
||||||
|
import static org.keycloak.common.Profile.Feature.OID4VC_VCI;
|
||||||
import static org.keycloak.common.util.ObjectUtil.isBlank;
|
import static org.keycloak.common.util.ObjectUtil.isBlank;
|
||||||
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
|
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
|
||||||
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
|
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
|
||||||
|
|
@ -81,6 +83,8 @@ import static org.keycloak.userprofile.UserProfileContext.REGISTRATION;
|
||||||
import static org.keycloak.userprofile.UserProfileContext.UPDATE_EMAIL;
|
import static org.keycloak.userprofile.UserProfileContext.UPDATE_EMAIL;
|
||||||
import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE;
|
import static org.keycloak.userprofile.UserProfileContext.UPDATE_PROFILE;
|
||||||
import static org.keycloak.userprofile.UserProfileContext.USER_API;
|
import static org.keycloak.userprofile.UserProfileContext.USER_API;
|
||||||
|
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN;
|
||||||
|
import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER;
|
||||||
|
|
||||||
public class DeclarativeUserProfileProviderFactory implements UserProfileProviderFactory, AmphibianProviderFactory<UserProfileProvider> {
|
public class DeclarativeUserProfileProviderFactory implements UserProfileProviderFactory, AmphibianProviderFactory<UserProfileProvider> {
|
||||||
|
|
||||||
|
|
@ -324,7 +328,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete cache so new config is parsed and applied next time it is required
|
// delete cache so new config is parsed and applied next time it is required
|
||||||
// throught #configureUserProfile(metadata, session)
|
// through #configureUserProfile(metadata, session)
|
||||||
if (model != null) {
|
if (model != null) {
|
||||||
model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY);
|
model.removeNote(DeclarativeUserProfileProvider.PARSED_CONFIG_COMPONENT_KEY);
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +498,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||||
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
||||||
|
|
||||||
metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE,
|
metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, -1, AttributeMetadata.ALWAYS_FALSE,
|
||||||
DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled)
|
DeclarativeUserProfileProviderFactory::isTermAndConditionsEnabled)
|
||||||
.setAttributeDisplayName("${termsAndConditionsUserAttribute}")
|
.setAttributeDisplayName("${termsAndConditionsUserAttribute}")
|
||||||
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
.setRequired(AttributeMetadata.ALWAYS_FALSE);
|
||||||
|
|
||||||
|
|
@ -521,21 +525,39 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initDefaultConfiguration(Scope config) {
|
private void initDefaultConfiguration(Scope config) {
|
||||||
|
|
||||||
// The user-defined configuration is always parsed during init and should be avoided as much as possible
|
// The user-defined configuration is always parsed during init and should be avoided as much as possible
|
||||||
// If no user-defined configuration is set, the system default configuration must have been set
|
// If no user-defined configuration is set, the system default configuration must have been set
|
||||||
// In Quarkus, the system default configuration is set at build time for optimization purposes
|
// In Quarkus, the system default configuration is set at build time for optimization purposes
|
||||||
UPConfig defaultConfig = ofNullable(config.get("configFile"))
|
UPConfig parsedConfig = ofNullable(config.get("configFile"))
|
||||||
.map(Paths::get)
|
.map(Paths::get)
|
||||||
.map(UPConfigUtils::parseConfig)
|
.map(UPConfigUtils::parseConfig)
|
||||||
.orElse(PARSED_DEFAULT_RAW_CONFIG);
|
.orElse(PARSED_DEFAULT_RAW_CONFIG);
|
||||||
|
|
||||||
if (defaultConfig == null) {
|
// As fallback parse the system default config
|
||||||
// as a fallback parse the system default config
|
if (parsedConfig == null) {
|
||||||
defaultConfig = UPConfigUtils.parseSystemDefaultConfig();
|
parsedConfig = UPConfigUtils.parseSystemDefaultConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
PARSED_DEFAULT_RAW_CONFIG = null;
|
// Modify the user profile to use when --features=oid4vc-vci is enabled
|
||||||
setDefaultConfig(defaultConfig);
|
if (Profile.isFeatureEnabled(OID4VC_VCI)) {
|
||||||
|
addUserDidAttribute(parsedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultConfig(parsedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addUserDidAttribute(UPConfig config) {
|
||||||
|
if (config.getAttribute(UserModel.DID) == null) {
|
||||||
|
UPAttribute attr = new UPAttribute(UserModel.DID);
|
||||||
|
attr.setDisplayName("${did}");
|
||||||
|
attr.setPermissions(new UPAttributePermissions(Set.of(ROLE_ADMIN, ROLE_USER), Set.of(ROLE_ADMIN, ROLE_USER)));
|
||||||
|
attr.setValidations(Map.of(PatternValidator.ID, Map.of(
|
||||||
|
"pattern", "^did:.+:.+$",
|
||||||
|
"error-message", "Value must start with 'did:scheme:'")));
|
||||||
|
config.addOrReplaceAttribute(attr);
|
||||||
|
PARSED_DEFAULT_RAW_CONFIG = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, Object> getEmailAnnotationDecorator(AttributeContext c) {
|
private static Map<String, Object> getEmailAnnotationDecorator(AttributeContext c) {
|
||||||
|
|
|
||||||
|
|
@ -298,18 +298,18 @@ public class UPConfigUtils {
|
||||||
if (isBlank(validator)) {
|
if (isBlank(validator)) {
|
||||||
errors.add("Validation without validator id is defined for attribute '" + attributeName + "'");
|
errors.add("Validation without validator id is defined for attribute '" + attributeName + "'");
|
||||||
} else {
|
} else {
|
||||||
if(session!=null) {
|
if(session!=null) {
|
||||||
if(Validators.validator(session, validator) == null) {
|
if(Validators.validator(session, validator) == null) {
|
||||||
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist");
|
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' doesn't exist");
|
||||||
} else {
|
} else {
|
||||||
ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig));
|
ValidationResult result = Validators.validateConfig(session, validator, ValidatorConfig.configFromMap(validatorConfig));
|
||||||
if(!result.isValid()) {
|
if(!result.isValid()) {
|
||||||
final StringBuilder sb = new StringBuilder();
|
final StringBuilder sb = new StringBuilder();
|
||||||
result.forEachError(err -> sb.append(err.toString()+", "));
|
result.forEachError(err -> sb.append(err.toString()+", "));
|
||||||
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString());
|
errors.add("Validator '" + validator + "' defined for attribute '" + attributeName + "' has incorrect configuration: " + sb.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,7 +339,7 @@ public class UPConfigUtils {
|
||||||
try (InputStream is = new FileInputStream(configPath.toFile())) {
|
try (InputStream is = new FileInputStream(configPath.toFile())) {
|
||||||
return parseConfig(is);
|
return parseConfig(is);
|
||||||
} catch (IOException ioe) {
|
} catch (IOException ioe) {
|
||||||
throw new RuntimeException("Failed to reaad default user profile configuration: " + configPath, ioe);
|
throw new RuntimeException("Failed to read default user profile configuration: " + configPath, ioe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,108 @@
|
||||||
{
|
{
|
||||||
"attributes": [
|
"attributes": [
|
||||||
{
|
{
|
||||||
"name": "username",
|
"name": "username",
|
||||||
"displayName": "${username}",
|
"displayName": "${username}",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"view": ["admin", "user"],
|
"view": [
|
||||||
"edit": ["admin", "user"]
|
"admin",
|
||||||
},
|
"user"
|
||||||
"validations": {
|
],
|
||||||
"length": { "min": 3, "max": 255 },
|
"edit": [
|
||||||
"username-prohibited-characters": {},
|
"admin",
|
||||||
"up-username-not-idn-homograph": {}
|
"user"
|
||||||
}
|
]
|
||||||
},
|
},
|
||||||
{
|
"validations": {
|
||||||
"name": "email",
|
"length": {
|
||||||
"displayName": "${email}",
|
"min": 3,
|
||||||
"required": {"roles" : ["user"]},
|
"max": 255
|
||||||
"permissions": {
|
},
|
||||||
"view": ["admin", "user"],
|
"username-prohibited-characters": {},
|
||||||
"edit": ["admin", "user"]
|
"up-username-not-idn-homograph": {}
|
||||||
},
|
}
|
||||||
"validations": {
|
},
|
||||||
"email" : {},
|
{
|
||||||
"length": { "max": 255 }
|
"name": "email",
|
||||||
}
|
"displayName": "${email}",
|
||||||
},
|
"required": {
|
||||||
{
|
"roles": [
|
||||||
"name": "firstName",
|
"user"
|
||||||
"displayName": "${firstName}",
|
]
|
||||||
"required": {"roles" : ["user"]},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"view": ["admin", "user"],
|
"view": [
|
||||||
"edit": ["admin", "user"]
|
"admin",
|
||||||
},
|
"user"
|
||||||
"validations": {
|
],
|
||||||
"length": { "max": 255 },
|
"edit": [
|
||||||
"person-name-prohibited-characters": {}
|
"admin",
|
||||||
}
|
"user"
|
||||||
},
|
]
|
||||||
{
|
},
|
||||||
"name": "lastName",
|
"validations": {
|
||||||
"displayName": "${lastName}",
|
"email": {},
|
||||||
"required": {"roles" : ["user"]},
|
"length": {
|
||||||
"permissions": {
|
"max": 255
|
||||||
"view": ["admin", "user"],
|
}
|
||||||
"edit": ["admin", "user"]
|
}
|
||||||
},
|
},
|
||||||
"validations": {
|
{
|
||||||
"length": { "max": 255 },
|
"name": "firstName",
|
||||||
"person-name-prohibited-characters": {}
|
"displayName": "${firstName}",
|
||||||
}
|
"required": {
|
||||||
}
|
"roles": [
|
||||||
],
|
"user"
|
||||||
"groups": [
|
]
|
||||||
{
|
},
|
||||||
"name": "user-metadata",
|
"permissions": {
|
||||||
"displayHeader": "User metadata",
|
"view": [
|
||||||
"displayDescription": "Attributes, which refer to user metadata"
|
"admin",
|
||||||
}
|
"user"
|
||||||
]
|
],
|
||||||
|
"edit": [
|
||||||
|
"admin",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validations": {
|
||||||
|
"length": {
|
||||||
|
"max": 255
|
||||||
|
},
|
||||||
|
"person-name-prohibited-characters": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lastName",
|
||||||
|
"displayName": "${lastName}",
|
||||||
|
"required": {
|
||||||
|
"roles": [
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"view": [
|
||||||
|
"admin",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"edit": [
|
||||||
|
"admin",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validations": {
|
||||||
|
"length": {
|
||||||
|
"max": 255
|
||||||
|
},
|
||||||
|
"person-name-prohibited-characters": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "user-metadata",
|
||||||
|
"displayHeader": "User metadata",
|
||||||
|
"displayDescription": "Attributes, which refer to user metadata"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
|
||||||
.orElse(new ArrayList<>());
|
.orElse(new ArrayList<>());
|
||||||
realmUsers.add(getUserRepresentation("John Doe", List.of(),
|
realmUsers.add(getUserRepresentation("John Doe", Map.of(), List.of(),
|
||||||
Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole"))));
|
Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole"))));
|
||||||
testRealm.setUsers(realmUsers);
|
testRealm.setUsers(realmUsers);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
import org.keycloak.representations.idm.RoleRepresentation;
|
import org.keycloak.representations.idm.RoleRepresentation;
|
||||||
import org.keycloak.representations.idm.RolesRepresentation;
|
import org.keycloak.representations.idm.RolesRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
import org.keycloak.representations.userprofile.config.UPConfig;
|
||||||
import org.keycloak.services.managers.AppAuthManager;
|
import org.keycloak.services.managers.AppAuthManager;
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.testsuite.Assert;
|
import org.keycloak.testsuite.Assert;
|
||||||
|
|
@ -105,6 +106,8 @@ import org.keycloak.testsuite.util.AdminClientUtil;
|
||||||
import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
|
import org.keycloak.testsuite.util.oauth.OpenIDProviderConfigurationResponse;
|
||||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
|
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
|
||||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||||
|
import org.keycloak.userprofile.DeclarativeUserProfileProviderFactory;
|
||||||
|
import org.keycloak.userprofile.config.UPConfigUtils;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
@ -123,6 +126,7 @@ import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENT
|
||||||
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
|
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
|
||||||
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
|
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
|
||||||
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
|
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
|
||||||
|
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY;
|
||||||
import static org.keycloak.util.JsonSerialization.valueAsPrettyString;
|
import static org.keycloak.util.JsonSerialization.valueAsPrettyString;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
@ -649,13 +653,16 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||||
testRealm.setComponents(new MultivaluedHashMap<>());
|
testRealm.setComponents(new MultivaluedHashMap<>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add key providers
|
||||||
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider());
|
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider());
|
||||||
|
|
||||||
testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
|
testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
|
||||||
getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100));
|
getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100));
|
||||||
testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
|
testRealm.getComponents().add("org.keycloak.keys.KeyProvider",
|
||||||
getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101));
|
getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101));
|
||||||
|
|
||||||
|
// Add Did attribute to the user profile
|
||||||
|
testRealm.getComponents().add("org.keycloak.userprofile.UserProfileProvider", getUserProfileProvider());
|
||||||
|
|
||||||
// Find existing client representation
|
// Find existing client representation
|
||||||
Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
|
Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
|
||||||
.collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity()));
|
.collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity()));
|
||||||
|
|
@ -678,11 +685,26 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||||
|
|
||||||
Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole"));
|
Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole"));
|
||||||
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>());
|
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>());
|
||||||
realmUsers.add(getUserRepresentation("John Doe", List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles));
|
realmUsers.add(getUserRepresentation("John Doe", Map.of("did", "did:key:1234"), List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles));
|
||||||
realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of()));
|
realmUsers.add(getUserRepresentation("Alice Wonderland", Map.of("did", "did:key:5678"), List.of(), Map.of()));
|
||||||
testRealm.setUsers(realmUsers);
|
testRealm.setUsers(realmUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ComponentExportRepresentation getUserProfileProvider() {
|
||||||
|
|
||||||
|
// Add the User DID attribute, with the same logic as in DeclarativeUserProfileProviderFactory
|
||||||
|
//
|
||||||
|
UPConfig profileConfig = UPConfigUtils.parseSystemDefaultConfig();
|
||||||
|
DeclarativeUserProfileProviderFactory.addUserDidAttribute(profileConfig);
|
||||||
|
|
||||||
|
ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation();
|
||||||
|
componentExportRepresentation.setProviderId("declarative-user-profile");
|
||||||
|
componentExportRepresentation.setConfig(new MultivaluedHashMap<>(
|
||||||
|
Map.of(UP_COMPONENT_CONFIG_KEY, List.of(JsonSerialization.valueAsString(profileConfig)))));
|
||||||
|
|
||||||
|
return componentExportRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
protected void withCausePropagation(Runnable r) throws Throwable {
|
protected void withCausePropagation(Runnable r) throws Throwable {
|
||||||
try {
|
try {
|
||||||
r.run();
|
r.run();
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ import org.apache.http.HttpStatus;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
|
@ -409,46 +411,36 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||||
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
|
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
|
||||||
jwtVcClaims);
|
jwtVcClaims);
|
||||||
|
|
||||||
assertEquals(4, jwtVcClaims.size());
|
assertEquals(5, jwtVcClaims.size());
|
||||||
{
|
{
|
||||||
Claim claim = jwtVcClaims.get(0);
|
Claim claim = jwtVcClaims.get(0);
|
||||||
assertEquals("The sd-jwt-credential claim email is present.",
|
assertEquals("id claim is present", CLAIM_NAME_SUBJECT_ID, claim.getPath().get(0));
|
||||||
"email",
|
assertFalse("id claim not mandatory.", claim.isMandatory());
|
||||||
claim.getPath().get(0));
|
assertNull("id has no display value", claim.getDisplay());
|
||||||
assertFalse("The sd-jwt-credential claim email is not mandatory.",
|
|
||||||
claim.isMandatory());
|
|
||||||
assertNull("The sd-jwt-credential claim email has no display configured",
|
|
||||||
claim.getDisplay());
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
Claim claim = jwtVcClaims.get(1);
|
Claim claim = jwtVcClaims.get(1);
|
||||||
assertEquals("The sd-jwt-credential claim firstName is present.",
|
assertEquals("email claim is present", "email", claim.getPath().get(0));
|
||||||
"firstName",
|
assertFalse("email claim not mandatory.", claim.isMandatory());
|
||||||
claim.getPath().get(0));
|
assertNull("email has no display value", claim.getDisplay());
|
||||||
assertFalse("The sd-jwt-credential claim firstName is not mandatory.",
|
|
||||||
claim.isMandatory());
|
|
||||||
assertNull("The sd-jwt-credential claim firstName has no display configured",
|
|
||||||
claim.getDisplay());
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
Claim claim = jwtVcClaims.get(2);
|
Claim claim = jwtVcClaims.get(2);
|
||||||
assertEquals("The sd-jwt-credential claim lastName is present.",
|
assertEquals("firstName claim is present", "firstName", claim.getPath().get(0));
|
||||||
"lastName",
|
assertFalse("firstName claim not mandatory.", claim.isMandatory());
|
||||||
claim.getPath().get(0));
|
assertNull("firstName has no display value", claim.getDisplay());
|
||||||
assertFalse("The sd-jwt-credential claim lastName is not mandatory.",
|
|
||||||
claim.isMandatory());
|
|
||||||
assertNull("The sd-jwt-credential claim lastName has no display configured",
|
|
||||||
claim.getDisplay());
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
Claim claim = jwtVcClaims.get(3);
|
Claim claim = jwtVcClaims.get(3);
|
||||||
assertEquals("The sd-jwt-credential claim scope-name is present.",
|
assertEquals("lastName claim is present", "lastName", claim.getPath().get(0));
|
||||||
"scope-name",
|
assertFalse("lastName claim not mandatory.", claim.isMandatory());
|
||||||
claim.getPath().get(0));
|
assertNull("lastName has no display value", claim.getDisplay());
|
||||||
assertFalse("The sd-jwt-credential claim scope-name is not mandatory.",
|
}
|
||||||
claim.isMandatory());
|
{
|
||||||
assertNull("The sd-jwt-credential claim scope-name has no display configured",
|
Claim claim = jwtVcClaims.get(4);
|
||||||
claim.getDisplay());
|
assertEquals("scope-name claim is present", "scope-name", claim.getPath().get(0));
|
||||||
|
assertFalse("scope-name claim not mandatory.", claim.isMandatory());
|
||||||
|
assertNull("scope-name has no display value", claim.getDisplay());
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals("The sd-jwt-credential should offer vct",
|
assertEquals("The sd-jwt-credential should offer vct",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
|
|
@ -65,6 +66,7 @@ import org.keycloak.jose.jwk.JWKBuilder;
|
||||||
import org.keycloak.jose.jws.JWSBuilder;
|
import org.keycloak.jose.jws.JWSBuilder;
|
||||||
import org.keycloak.jose.jws.JWSInputException;
|
import org.keycloak.jose.jws.JWSInputException;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
|
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
|
||||||
|
|
@ -89,9 +91,11 @@ import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||||
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||||
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
import org.keycloak.representations.idm.RoleRepresentation;
|
import org.keycloak.representations.idm.RoleRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.services.resources.RealmsResource;
|
import org.keycloak.services.resources.RealmsResource;
|
||||||
|
import org.keycloak.testsuite.AbstractAdminTest;
|
||||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
import org.keycloak.testsuite.util.AdminClientUtil;
|
import org.keycloak.testsuite.util.AdminClientUtil;
|
||||||
|
|
@ -109,6 +113,7 @@ import org.junit.Assert;
|
||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
|
||||||
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
|
||||||
|
import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID;
|
||||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest.TIME_PROVIDER;
|
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest.TIME_PROVIDER;
|
||||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer;
|
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getCredentialIssuer;
|
||||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getJtiGeneratedIdMapper;
|
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getJtiGeneratedIdMapper;
|
||||||
|
|
@ -145,6 +150,15 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||||
AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
|
AuthorizationDetailsParser.registerParser(OPENID_CREDENTIAL, new OID4VCAuthorizationDetailsProcessor.OID4VCAuthorizationDetailsParser());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||||
|
log.debug("Adding test realm for import from testrealm.json");
|
||||||
|
InputStream inputStream = getClass().getResourceAsStream("/testrealm.json");
|
||||||
|
RealmRepresentation testRealm = AbstractAdminTest.loadJson(inputStream, RealmRepresentation.class);
|
||||||
|
testRealms.add(testRealm);
|
||||||
|
configureTestRealm(testRealm);
|
||||||
|
}
|
||||||
|
|
||||||
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
|
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
|
||||||
CredentialSubject credentialSubject = new CredentialSubject();
|
CredentialSubject credentialSubject = new CredentialSubject();
|
||||||
claims.forEach(credentialSubject::setClaims);
|
claims.forEach(credentialSubject::setClaims);
|
||||||
|
|
@ -336,6 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||||
|
|
||||||
public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
|
public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
|
||||||
return List.of(
|
return List.of(
|
||||||
|
getSubjectIdMapper(CLAIM_NAME_SUBJECT_ID, UserModel.DID),
|
||||||
getUserAttributeMapper("email", "email"),
|
getUserAttributeMapper("email", "email"),
|
||||||
getUserAttributeMapper("firstName", "firstName"),
|
getUserAttributeMapper("firstName", "firstName"),
|
||||||
getUserAttributeMapper("lastName", "lastName"),
|
getUserAttributeMapper("lastName", "lastName"),
|
||||||
|
|
@ -409,6 +424,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||||
|
|
||||||
public static UserRepresentation getUserRepresentation(
|
public static UserRepresentation getUserRepresentation(
|
||||||
String fullName,
|
String fullName,
|
||||||
|
Map<String, String> attributes,
|
||||||
List<String> realmRoles,
|
List<String> realmRoles,
|
||||||
Map<String, List<String>> clientRoles
|
Map<String, List<String>> clientRoles
|
||||||
) {
|
) {
|
||||||
|
|
@ -428,6 +444,8 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||||
.role("account", "manage-account")
|
.role("account", "manage-account")
|
||||||
.role("account", "view-profile");
|
.role("account", "view-profile");
|
||||||
|
|
||||||
|
attributes.forEach(userBuilder::addAttribute);
|
||||||
|
|
||||||
// When Keycloak issues a token for a user and client:
|
// When Keycloak issues a token for a user and client:
|
||||||
//
|
//
|
||||||
// 1. It looks up all effective realm roles and all effective client roles assigned to the user.
|
// 1. It looks up all effective realm roles and all effective client roles assigned to the user.
|
||||||
|
|
@ -534,6 +552,18 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||||
return protocolMapperRepresentation;
|
return protocolMapperRepresentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected ProtocolMapperRepresentation getSubjectIdMapper(String subjectProperty, String attributeName) {
|
||||||
|
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||||
|
protocolMapperRepresentation.setName(attributeName + "-mapper");
|
||||||
|
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
|
||||||
|
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||||
|
protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper");
|
||||||
|
protocolMapperRepresentation.setConfig(Map.of(
|
||||||
|
"claim.name", subjectProperty,
|
||||||
|
"userAttribute", attributeName));
|
||||||
|
return protocolMapperRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
|
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
|
||||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||||
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
|
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"protocolMapper": "oid4vc-subject-id-mapper",
|
"protocolMapper": "oid4vc-subject-id-mapper",
|
||||||
"config": {
|
"config": {
|
||||||
"claim.name": "id",
|
"claim.name": "id",
|
||||||
"userAttribute": "id",
|
"userAttribute": "did",
|
||||||
"vc.mandatory": "false"
|
"vc.mandatory": "false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ currentPassword=Current Password
|
||||||
passwordConfirm=Confirmation
|
passwordConfirm=Confirmation
|
||||||
passwordNew=New Password
|
passwordNew=New Password
|
||||||
username=Username
|
username=Username
|
||||||
|
did=Identity (DID)
|
||||||
address=Address
|
address=Address
|
||||||
street=Street
|
street=Street
|
||||||
locality=City or Locality
|
locality=City or Locality
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue