Closes #29859 Signed-off-by: Ingrid Kamga <Ingrid.Kamga@adorsys.com>
This commit is contained in:
parent
f994cc54d5
commit
c4d6979907
27 changed files with 1788 additions and 145 deletions
43
core/src/main/java/org/keycloak/crypto/ECCurve.java
Normal file
43
core/src/main/java/org/keycloak/crypto/ECCurve.java
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2024 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.crypto;
|
||||
|
||||
public enum ECCurve {
|
||||
P256,
|
||||
P384,
|
||||
P521;
|
||||
|
||||
/**
|
||||
* Convert standard EC curve names (and aliases) into this enum.
|
||||
*/
|
||||
public static ECCurve fromStdCrv(String crv) {
|
||||
switch (crv) {
|
||||
case "P-256":
|
||||
case "secp256r1":
|
||||
return P256;
|
||||
case "P-384":
|
||||
case "secp384r1":
|
||||
return P384;
|
||||
case "P-521":
|
||||
case "secp521r1":
|
||||
return P521;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unexpected EC curve: " + crv);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,34 +17,25 @@
|
|||
|
||||
package org.keycloak.sdjwt;
|
||||
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
|
||||
/**
|
||||
* Options for Issuer-signed JWT verification.
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class IssuerSignedJwtVerificationOpts {
|
||||
private final SignatureVerifierContext verifier;
|
||||
private final boolean validateIssuedAtClaim;
|
||||
private final boolean validateExpirationClaim;
|
||||
private final boolean validateNotBeforeClaim;
|
||||
|
||||
public IssuerSignedJwtVerificationOpts(
|
||||
SignatureVerifierContext verifier,
|
||||
boolean validateIssuedAtClaim,
|
||||
boolean validateExpirationClaim,
|
||||
boolean validateNotBeforeClaim) {
|
||||
this.verifier = verifier;
|
||||
this.validateIssuedAtClaim = validateIssuedAtClaim;
|
||||
this.validateExpirationClaim = validateExpirationClaim;
|
||||
this.validateNotBeforeClaim = validateNotBeforeClaim;
|
||||
}
|
||||
|
||||
public SignatureVerifierContext getVerifier() {
|
||||
return verifier;
|
||||
}
|
||||
|
||||
public boolean mustValidateIssuedAtClaim() {
|
||||
return validateIssuedAtClaim;
|
||||
}
|
||||
|
@ -62,16 +53,10 @@ public class IssuerSignedJwtVerificationOpts {
|
|||
}
|
||||
|
||||
public static class Builder {
|
||||
private SignatureVerifierContext verifier;
|
||||
private boolean validateIssuedAtClaim;
|
||||
private boolean validateExpirationClaim = true;
|
||||
private boolean validateNotBeforeClaim = true;
|
||||
|
||||
public Builder withVerifier(SignatureVerifierContext verifier) {
|
||||
this.verifier = verifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withValidateIssuedAtClaim(boolean validateIssuedAtClaim) {
|
||||
this.validateIssuedAtClaim = validateIssuedAtClaim;
|
||||
return this;
|
||||
|
@ -89,7 +74,6 @@ public class IssuerSignedJwtVerificationOpts {
|
|||
|
||||
public IssuerSignedJwtVerificationOpts build() {
|
||||
return new IssuerSignedJwtVerificationOpts(
|
||||
verifier,
|
||||
validateIssuedAtClaim,
|
||||
validateExpirationClaim,
|
||||
validateNotBeforeClaim
|
||||
|
|
98
core/src/main/java/org/keycloak/sdjwt/JwkParsingUtils.java
Normal file
98
core/src/main/java/org/keycloak/sdjwt/JwkParsingUtils.java
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
|
||||
import org.keycloak.crypto.ECCurve;
|
||||
import org.keycloak.crypto.ECDSASignatureVerifierContext;
|
||||
import org.keycloak.crypto.KeyType;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.util.JWKSUtils;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class JwkParsingUtils {
|
||||
|
||||
public static SignatureVerifierContext convertJwkNodeToVerifierContext(JsonNode jwkNode) {
|
||||
JWK jwk;
|
||||
|
||||
try {
|
||||
jwk = SdJwtUtils.mapper.convertValue(jwkNode, JWK.class);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Malformed JWK");
|
||||
}
|
||||
|
||||
return convertJwkToVerifierContext(jwk);
|
||||
}
|
||||
|
||||
public static SignatureVerifierContext convertJwkToVerifierContext(JWK jwk) {
|
||||
// Wrap JWK
|
||||
|
||||
KeyWrapper keyWrapper;
|
||||
|
||||
try {
|
||||
keyWrapper = JWKSUtils.getKeyWrapper(jwk);
|
||||
Objects.requireNonNull(keyWrapper);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Unsupported or invalid JWK");
|
||||
}
|
||||
|
||||
// Build verifier
|
||||
|
||||
// KeyType.EC
|
||||
if (keyWrapper.getType().equals(KeyType.EC)) {
|
||||
if (keyWrapper.getAlgorithm() == null) {
|
||||
Objects.requireNonNull(keyWrapper.getCurve());
|
||||
|
||||
String alg = null;
|
||||
switch (ECCurve.fromStdCrv(keyWrapper.getCurve())) {
|
||||
case P256:
|
||||
alg = Algorithm.ES256;
|
||||
break;
|
||||
case P384:
|
||||
alg = Algorithm.ES384;
|
||||
break;
|
||||
case P521:
|
||||
alg = Algorithm.ES512;
|
||||
break;
|
||||
}
|
||||
|
||||
keyWrapper.setAlgorithm(alg);
|
||||
}
|
||||
|
||||
return new ECDSASignatureVerifierContext(keyWrapper);
|
||||
}
|
||||
|
||||
// KeyType.RSA
|
||||
if (keyWrapper.getType().equals(KeyType.RSA)) {
|
||||
return new AsymmetricSignatureVerifierContext(keyWrapper);
|
||||
}
|
||||
|
||||
// KeyType is not supported
|
||||
// This is unreachable as of now given that `JWKSUtils.getKeyWrapper` will fail
|
||||
// on JWKs with key type not equal to EC or RSA.
|
||||
throw new IllegalArgumentException("Unexpected key type: " + keyWrapper.getType());
|
||||
}
|
||||
}
|
|
@ -38,6 +38,9 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||
*
|
||||
*/
|
||||
public abstract class SdJws {
|
||||
|
||||
public static final String CLAIM_NAME_ISSUER = "iss";
|
||||
|
||||
private final JWSInput jwsInput;
|
||||
private final JsonNode payload;
|
||||
|
||||
|
@ -158,7 +161,7 @@ public abstract class SdJws {
|
|||
* @param issuers List of trusted issuers
|
||||
*/
|
||||
public void verifyIssClaim(List<String> issuers) throws VerificationException {
|
||||
verifyClaimAgainstTrustedValues(issuers, "iss");
|
||||
verifyClaimAgainstTrustedValues(issuers, CLAIM_NAME_ISSUER);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,6 +26,7 @@ import java.util.stream.IntStream;
|
|||
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureSignerContext;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.sdjwt.vp.KeyBindingJWT;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
@ -44,6 +45,7 @@ public class SdJwt {
|
|||
private final IssuerSignedJWT issuerSignedJWT;
|
||||
private final List<SdJwtClaim> claims;
|
||||
private final List<String> disclosures = new ArrayList<>();
|
||||
private final SdJwtVerificationContext sdJwtVerificationContext;
|
||||
|
||||
private SdJwt(DisclosureSpec disclosureSpec, JsonNode claimSet, List<SdJwt> nesteSdJwts,
|
||||
Optional<KeyBindingJWT> keyBindingJWT,
|
||||
|
@ -65,6 +67,12 @@ public class SdJwt {
|
|||
|
||||
nesteSdJwts.stream().forEach(nestedJwt -> this.disclosures.addAll(nestedJwt.getDisclosures()));
|
||||
this.disclosures.addAll(getDisclosureStrings(claims));
|
||||
|
||||
// Instantiate context for verification
|
||||
this.sdJwtVerificationContext = new SdJwtVerificationContext(
|
||||
this.issuerSignedJWT,
|
||||
this.disclosures
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<String> sdJwtString = Optional.empty();
|
||||
|
@ -198,14 +206,21 @@ public class SdJwt {
|
|||
/**
|
||||
* Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid.
|
||||
*
|
||||
* @param verificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
|
||||
* must be specified for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that associated public keys
|
||||
* belong to the intended issuer.
|
||||
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that the keys belong
|
||||
* to the intended issuer.
|
||||
* @param verificationOpts Options to parameterize the Issuer-Signed JWT verification.
|
||||
* @throws VerificationException if verification failed
|
||||
*/
|
||||
public void verify(IssuerSignedJwtVerificationOpts verificationOpts) throws VerificationException {
|
||||
new SdJwtVerificationContext(issuerSignedJWT, disclosures).verifyIssuance(verificationOpts);
|
||||
public void verify(
|
||||
List<SignatureVerifierContext> issuerVerifyingKeys,
|
||||
IssuerSignedJwtVerificationOpts verificationOpts
|
||||
) throws VerificationException {
|
||||
sdJwtVerificationContext.verifyIssuance(
|
||||
issuerVerifyingKeys,
|
||||
verificationOpts,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// builder for SdJwt
|
||||
|
|
|
@ -20,22 +20,19 @@ package org.keycloak.sdjwt;
|
|||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
|
||||
import org.keycloak.crypto.ECDSASignatureVerifierContext;
|
||||
import org.keycloak.crypto.KeyType;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.sdjwt.consumer.PresentationRequirements;
|
||||
import org.keycloak.sdjwt.vp.KeyBindingJWT;
|
||||
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
|
||||
import org.keycloak.util.JWKSUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.AbstractMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
@ -48,6 +45,9 @@ import java.util.stream.Collectors;
|
|||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class SdJwtVerificationContext {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(SdJwtVerificationContext.class.getName());
|
||||
|
||||
private String sdJwtVpString;
|
||||
|
||||
private final IssuerSignedJWT issuerSignedJwt;
|
||||
|
@ -79,7 +79,7 @@ public class SdJwtVerificationContext {
|
|||
.map(disclosureString -> {
|
||||
String digest = SdJwtUtils.hashAndBase64EncodeNoPad(
|
||||
disclosureString.getBytes(), issuerSignedJwt.getSdHashAlg());
|
||||
return new AbstractMap.SimpleEntry<String,String>(digest, disclosureString);
|
||||
return new AbstractMap.SimpleEntry<>(digest, disclosureString);
|
||||
})
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
}
|
||||
|
@ -92,17 +92,21 @@ public class SdJwtVerificationContext {
|
|||
* - all Disclosures are valid and correspond to a respective digest value in the Issuer-signed JWT
|
||||
* (directly in the payload or recursively included in the contents of other Disclosures).
|
||||
*
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
|
||||
* must be specified for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that associated public keys
|
||||
* belong to the intended issuer.
|
||||
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that the keys belong
|
||||
* to the intended issuer.
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
|
||||
* @param presentationRequirements If set, the presentation requirements will be enforced upon fully
|
||||
* disclosing the Issuer-signed JWT during the verification.
|
||||
* @throws VerificationException if verification failed
|
||||
*/
|
||||
public void verifyIssuance(
|
||||
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts
|
||||
List<SignatureVerifierContext> issuerVerifyingKeys,
|
||||
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
|
||||
PresentationRequirements presentationRequirements
|
||||
) throws VerificationException {
|
||||
// Validate the Issuer-signed JWT.
|
||||
validateIssuerSignedJwt(issuerSignedJwtVerificationOpts.getVerifier());
|
||||
validateIssuerSignedJwt(issuerVerifyingKeys);
|
||||
|
||||
// Validate disclosures.
|
||||
JsonNode disclosedPayload = validateDisclosuresDigests();
|
||||
|
@ -112,6 +116,11 @@ public class SdJwtVerificationContext {
|
|||
// SD-JWT payload, but there is no guarantee they would do so. Therefore, Verifiers cannot reliably
|
||||
// depend on that and need to operate as though security-critical claims might be selectively disclosable.
|
||||
validateIssuerSignedJwtTimeClaims(disclosedPayload, issuerSignedJwtVerificationOpts);
|
||||
|
||||
// Enforce presentation requirements.
|
||||
if (presentationRequirements != null) {
|
||||
presentationRequirements.checkIfSatisfiedBy(disclosedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -122,18 +131,22 @@ public class SdJwtVerificationContext {
|
|||
* to ensure that if Key Binding is required, the Key Binding JWT is signed by the Holder and valid.
|
||||
* </p>
|
||||
*
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
|
||||
* must be specified for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that associated public keys
|
||||
* belong to the intended issuer.
|
||||
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that the keys belong
|
||||
* to the intended issuer.
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
|
||||
* @param keyBindingJwtVerificationOpts Options to parameterize the Key Binding JWT verification.
|
||||
* Must, among others, specify the Verifier's policy whether
|
||||
* to check Key Binding.
|
||||
* @param presentationRequirements If set, the presentation requirements will be enforced upon fully
|
||||
* disclosing the Issuer-signed JWT during the verification.
|
||||
* @throws VerificationException if verification failed
|
||||
*/
|
||||
public void verifyPresentation(
|
||||
List<SignatureVerifierContext> issuerVerifyingKeys,
|
||||
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
|
||||
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
|
||||
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts,
|
||||
PresentationRequirements presentationRequirements
|
||||
) throws VerificationException {
|
||||
// If Key Binding is required and a Key Binding JWT is not provided,
|
||||
// the Verifier MUST reject the Presentation.
|
||||
|
@ -142,7 +155,7 @@ public class SdJwtVerificationContext {
|
|||
}
|
||||
|
||||
// Upon receiving a Presentation, in addition to the checks in {@link #verifyIssuance}...
|
||||
verifyIssuance(issuerSignedJwtVerificationOpts);
|
||||
verifyIssuance(issuerVerifyingKeys, issuerSignedJwtVerificationOpts, presentationRequirements);
|
||||
|
||||
// Validate Key Binding JWT if required
|
||||
if (keyBindingJwtVerificationOpts.isKeyBindingRequired()) {
|
||||
|
@ -158,18 +171,32 @@ public class SdJwtVerificationContext {
|
|||
* - the Issuer-signed JWT is valid, i.e., it is signed by the Issuer and the signature is valid
|
||||
* </p>
|
||||
*
|
||||
* @param verifiers Verifying keys for validating the Issuer-signed JWT.
|
||||
* @throws VerificationException if verification failed
|
||||
*/
|
||||
private void validateIssuerSignedJwt(SignatureVerifierContext verifier) throws VerificationException {
|
||||
private void validateIssuerSignedJwt(
|
||||
List<SignatureVerifierContext> verifiers
|
||||
) throws VerificationException {
|
||||
// Check that the _sd_alg claim value is understood and the hash algorithm is deemed secure
|
||||
issuerSignedJwt.verifySdHashAlgorithm();
|
||||
|
||||
// Validate the signature over the Issuer-signed JWT
|
||||
try {
|
||||
issuerSignedJwt.verifySignature(verifier);
|
||||
} catch (VerificationException e) {
|
||||
throw new VerificationException("Invalid Issuer-Signed JWT", e);
|
||||
Iterator<SignatureVerifierContext> iterator = verifiers.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
try {
|
||||
SignatureVerifierContext verifier = iterator.next();
|
||||
issuerSignedJwt.verifySignature(verifier);
|
||||
return;
|
||||
} catch (VerificationException e) {
|
||||
logger.debugf(e, "Issuer-signed JWT's signature verification failed against one potential verifying key");
|
||||
if (iterator.hasNext()) {
|
||||
logger.debugf("Retrying Issuer-signed JWT's signature verification with next potential verifying key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No potential verifier could verify the JWT's signature
|
||||
throw new VerificationException("Invalid Issuer-Signed JWT: Signature could not be verified");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -241,51 +268,12 @@ public class SdJwtVerificationContext {
|
|||
throw new UnsupportedOperationException("Only cnf/jwk claim supported");
|
||||
}
|
||||
|
||||
// Parse JWK
|
||||
KeyWrapper keyWrapper;
|
||||
// Convert JWK
|
||||
try {
|
||||
JWK jwk = SdJwtUtils.mapper.convertValue(cnfJwk, JWK.class);
|
||||
keyWrapper = JWKSUtils.getKeyWrapper(jwk);
|
||||
Objects.requireNonNull(keyWrapper);
|
||||
return JwkParsingUtils.convertJwkNodeToVerifierContext(cnfJwk);
|
||||
} catch (Exception e) {
|
||||
throw new VerificationException("Malformed or unsupported cnf/jwk claim");
|
||||
throw new VerificationException("Could not process cnf/jwk", e);
|
||||
}
|
||||
|
||||
// Build verifier
|
||||
|
||||
// KeyType.EC
|
||||
if (keyWrapper.getType().equals(KeyType.EC)) {
|
||||
if (keyWrapper.getAlgorithm() == null) {
|
||||
Objects.requireNonNull(keyWrapper.getCurve());
|
||||
|
||||
String alg = null;
|
||||
switch (keyWrapper.getCurve()) {
|
||||
case "P-256":
|
||||
alg = "ES256";
|
||||
break;
|
||||
case "P-384":
|
||||
alg = "ES384";
|
||||
break;
|
||||
case "P-521":
|
||||
alg = "ES512";
|
||||
break;
|
||||
}
|
||||
|
||||
keyWrapper.setAlgorithm(alg);
|
||||
}
|
||||
|
||||
return new ECDSASignatureVerifierContext(keyWrapper);
|
||||
}
|
||||
|
||||
// KeyType.RSA
|
||||
if (keyWrapper.getType().equals(KeyType.RSA)) {
|
||||
return new AsymmetricSignatureVerifierContext(keyWrapper);
|
||||
}
|
||||
|
||||
// KeyType is not supported
|
||||
// This is unreachable as of now given that `JWKSUtils.getKeyWrapper` will fail
|
||||
// on JWKs with key type not equal to EC or RSA.
|
||||
throw new VerificationException("cnf/jwk alg is unsupported or deemed not secure");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -586,10 +574,10 @@ public class SdJwtVerificationContext {
|
|||
|
||||
// If the claim name is _sd or ..., the SD-JWT MUST be rejected.
|
||||
|
||||
List<String> denylist = Arrays.asList(new String[]{
|
||||
List<String> denylist = Arrays.asList(
|
||||
IssuerSignedJWT.CLAIM_NAME_SELECTIVE_DISCLOSURE,
|
||||
UndisclosedArrayElement.SD_CLAIM_NAME
|
||||
});
|
||||
);
|
||||
|
||||
String claimName = arrayNode.get(1).asText();
|
||||
if (denylist.contains(claimName)) {
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public interface HttpDataFetcher {
|
||||
|
||||
/**
|
||||
* Performs an HTTP GET at the URI and parses the response as JSON
|
||||
* @throws IOException if I/O error or HTTP status not OK (200)
|
||||
*/
|
||||
JsonNode fetchJsonData(String uri) throws IOException;
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.keycloak.jose.jwk.JSONWebKeySet;
|
||||
|
||||
/**
|
||||
* POJO for JWT VC Metadata
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-05#name-jwt-vc-issuer-metadata-resp">
|
||||
* JWT VC Issuer Metadata Response
|
||||
* </a>
|
||||
*/
|
||||
public class JwtVcMetadata {
|
||||
|
||||
@JsonProperty("issuer")
|
||||
private String issuer;
|
||||
|
||||
@JsonProperty("jwks_uri")
|
||||
private String jwksUri;
|
||||
|
||||
@JsonProperty("jwks")
|
||||
private JSONWebKeySet jwks;
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(String issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
|
||||
public String getJwksUri() {
|
||||
return jwksUri;
|
||||
}
|
||||
|
||||
public void setJwksUri(String jwksUri) {
|
||||
this.jwksUri = jwksUri;
|
||||
}
|
||||
|
||||
public JSONWebKeySet getJwks() {
|
||||
return jwks;
|
||||
}
|
||||
|
||||
public void setJwks(JSONWebKeySet jwks) {
|
||||
this.jwks = jwks;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.jose.jwk.JSONWebKeySet;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
import org.keycloak.sdjwt.JwkParsingUtils;
|
||||
import org.keycloak.sdjwt.SdJws;
|
||||
import org.keycloak.sdjwt.SdJwtUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* A trusted Issuer for running SD-JWT VP verification.
|
||||
*
|
||||
* <p>
|
||||
* This implementation targets issuers exposing verifying keys on a normalized JWT VC Issuer metadata endpoint.
|
||||
* </p>
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-05#name-issuer-signed-jwt-verificat">
|
||||
* JWT VC Issuer Metadata
|
||||
* </a>
|
||||
*/
|
||||
public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
|
||||
|
||||
private static final String JWT_VC_ISSUER_END_POINT = "/.well-known/jwt-vc-issuer";
|
||||
|
||||
private final Pattern issuerUriPattern;
|
||||
private final HttpDataFetcher httpDataFetcher;
|
||||
|
||||
/**
|
||||
* @param issuerUri a trusted issuer URI
|
||||
*/
|
||||
public JwtVcMetadataTrustedSdJwtIssuer(String issuerUri, HttpDataFetcher httpDataFetcher) {
|
||||
try {
|
||||
validateHttpsIssuerUri(issuerUri);
|
||||
} catch (VerificationException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
// Build a Regex pattern to only match the argument URI
|
||||
this.issuerUriPattern = Pattern.compile(Pattern.quote(issuerUri));
|
||||
|
||||
// Assign HttpDataFetcher implementation
|
||||
this.httpDataFetcher = httpDataFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param issuerUriPattern a regex pattern for trusted issuer URIs
|
||||
*/
|
||||
public JwtVcMetadataTrustedSdJwtIssuer(Pattern issuerUriPattern, HttpDataFetcher httpDataFetcher) {
|
||||
this.issuerUriPattern = issuerUriPattern;
|
||||
this.httpDataFetcher = httpDataFetcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT)
|
||||
throws VerificationException {
|
||||
// Read iss (claim) and kid (header)
|
||||
String iss = Optional.ofNullable(issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER))
|
||||
.map(JsonNode::asText)
|
||||
.orElse("");
|
||||
String kid = issuerSignedJWT.getHeader().getKeyId();
|
||||
|
||||
// Match the read iss claim against the trusted pattern
|
||||
Matcher matcher = issuerUriPattern.matcher(iss);
|
||||
if (!matcher.matches()) {
|
||||
throw new VerificationException(String.format(
|
||||
"Unexpected Issuer URI claim. Expected=/%s/, Got=%s",
|
||||
issuerUriPattern.pattern(), iss
|
||||
));
|
||||
}
|
||||
|
||||
// As per specs, only HTTPS URIs are supported
|
||||
validateHttpsIssuerUri(iss);
|
||||
|
||||
// Fetch exposed JWKs
|
||||
List<JWK> jwks = fetchIssuerMetadataJwks(iss);
|
||||
if (jwks.isEmpty()) {
|
||||
throw new VerificationException(
|
||||
String.format("Issuer JWKs were unexpectedly resolved to an empty list. Issuer URI: %s", iss)
|
||||
);
|
||||
}
|
||||
|
||||
// If kid specified, only consider the (single) matching key
|
||||
if (kid != null) {
|
||||
List<JWK> matchingJwks = jwks.stream()
|
||||
.filter(jwk -> {
|
||||
String jwkKid = jwk.getKeyId();
|
||||
return jwkKid != null && jwkKid.equals(kid);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (matchingJwks.isEmpty()) {
|
||||
throw new VerificationException(
|
||||
String.format("No published JWK was found to match kid: %s", kid)
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingJwks.size() > 1) {
|
||||
throw new VerificationException(
|
||||
String.format("Cannot choose between multiple exposed JWKs with same kid: %s", kid)
|
||||
);
|
||||
}
|
||||
|
||||
jwks = Collections.singletonList(matchingJwks.get(0));
|
||||
}
|
||||
|
||||
// Build SignatureVerifierContext's
|
||||
List<SignatureVerifierContext> verifiers = new ArrayList<>();
|
||||
for (JWK jwk : jwks) {
|
||||
try {
|
||||
verifiers.add(JwkParsingUtils.convertJwkToVerifierContext(jwk));
|
||||
} catch (Exception e) {
|
||||
throw new VerificationException("A potential JWK was retrieved but found invalid", e);
|
||||
}
|
||||
}
|
||||
|
||||
return verifiers;
|
||||
}
|
||||
|
||||
private void validateHttpsIssuerUri(String issuerUri) throws VerificationException {
|
||||
if (!issuerUri.startsWith("https://")) {
|
||||
throw new VerificationException(
|
||||
"HTTPS URI required to retrieve JWT VC Issuer Metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<JWK> fetchIssuerMetadataJwks(String issuerUri) throws VerificationException {
|
||||
// Build full URL to JWT VC metadata endpoint
|
||||
|
||||
issuerUri = normalizeUri(issuerUri);
|
||||
String jwtVcIssuerUri = issuerUri
|
||||
.concat(JWT_VC_ISSUER_END_POINT); // Append well-known path
|
||||
|
||||
// Fetch and parse metadata
|
||||
|
||||
JwtVcMetadata issuerMetadata;
|
||||
JsonNode issuerMetadataNode = fetchData(jwtVcIssuerUri);
|
||||
|
||||
try {
|
||||
issuerMetadata = SdJwtUtils.mapper.treeToValue(issuerMetadataNode, JwtVcMetadata.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new VerificationException("Failed to parse exposed JWT VC Metadata", e);
|
||||
}
|
||||
|
||||
// Validate metadata
|
||||
|
||||
String exposedIssuerUri = normalizeUri(issuerMetadata.getIssuer());
|
||||
|
||||
if (!issuerUri.equals(exposedIssuerUri)) {
|
||||
throw new VerificationException(String.format(
|
||||
"Unexpected metadata's issuer. Expected=%s, Got=%s",
|
||||
issuerUri, exposedIssuerUri
|
||||
));
|
||||
}
|
||||
|
||||
// Extract exposed JWKS (including dereferencing if necessary)
|
||||
|
||||
String jwksUri = issuerMetadata.getJwksUri();
|
||||
JSONWebKeySet jwks = issuerMetadata.getJwks();
|
||||
|
||||
if (jwks == null && jwksUri != null) {
|
||||
// Dereference JWKS URI
|
||||
JsonNode jwksNode = fetchData(jwksUri);
|
||||
|
||||
// Parse fetched JWKS
|
||||
try {
|
||||
jwks = SdJwtUtils.mapper.treeToValue(jwksNode, JSONWebKeySet.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new VerificationException("Failed to parse exposed JWKS", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (jwks == null || jwks.getKeys() == null) {
|
||||
throw new VerificationException(
|
||||
String.format("Could not resolve issuer JWKs with URI: %s", issuerUri));
|
||||
}
|
||||
|
||||
return Arrays.asList(jwks.getKeys());
|
||||
}
|
||||
|
||||
private JsonNode fetchData(String uri) throws VerificationException {
|
||||
try {
|
||||
return Objects.requireNonNull(httpDataFetcher.fetchJsonData(uri));
|
||||
} catch (Exception exception) {
|
||||
throw new VerificationException(
|
||||
String.format("Could not fetch data from URI: %s", uri),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUri(String uri) {
|
||||
// Remove any trailing slash
|
||||
return uri.replaceAll("/$", "");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.keycloak.common.VerificationException;
|
||||
|
||||
/**
|
||||
* Presentation requirements to constrain the kind of credential expected.
|
||||
*
|
||||
* <p>
|
||||
* This mirrors the idea of the expressive
|
||||
* <a href="https://identity.foundation/presentation-exchange/#presentation-definition">DIF Presentation Definition</a>,
|
||||
* while enabling simpler alternatives.
|
||||
* </p>
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public interface PresentationRequirements {
|
||||
|
||||
/**
|
||||
* Ensures that the configured requirements are satisfied by the presentation.
|
||||
*
|
||||
* @param disclosedPayload The fully disclosed Issuer-signed JWT of the presented token.
|
||||
* @throws VerificationException if the configured requirements are not satisfied.
|
||||
*/
|
||||
void checkIfSatisfiedBy(JsonNode disclosedPayload) throws VerificationException;
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A component for consuming (verifying) SD-JWT presentations.
|
||||
*
|
||||
* <p>
|
||||
* The purpose is to streamline SD-JWT VP verification beyond signature
|
||||
* and disclosure checks of {@link org.keycloak.sdjwt.SdJwtVerificationContext}
|
||||
* </p>
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class SdJwtPresentationConsumer {
|
||||
|
||||
/**
|
||||
* Verify SD-JWT presentation against specific requirements.
|
||||
*
|
||||
* @param sdJwtVP the presentation to verify
|
||||
* @param presentationRequirements the requirements on presented claims
|
||||
* @param trustedSdJwtIssuers trusted issuers for the verification
|
||||
* @param issuerSignedJwtVerificationOpts policy for Issuer-signed JWT verification
|
||||
* @param keyBindingJwtVerificationOpts policy for Key-binding JWT verification
|
||||
* @throws VerificationException if the verification fails for some reason
|
||||
*/
|
||||
public void verifySdJwtPresentation(
|
||||
SdJwtVP sdJwtVP,
|
||||
PresentationRequirements presentationRequirements,
|
||||
List<TrustedSdJwtIssuer> trustedSdJwtIssuers,
|
||||
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
|
||||
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
|
||||
) throws VerificationException {
|
||||
// Retrieve verifying keys for Issuer-signed JWT
|
||||
IssuerSignedJWT issuerSignedJWT = sdJwtVP.getIssuerSignedJWT();
|
||||
List<SignatureVerifierContext> issuerVerifyingKeys = new ArrayList<>();
|
||||
for (TrustedSdJwtIssuer trustedSdJwtIssuer : trustedSdJwtIssuers) {
|
||||
List<SignatureVerifierContext> keys = trustedSdJwtIssuer
|
||||
.resolveIssuerVerifyingKeys(issuerSignedJWT);
|
||||
issuerVerifyingKeys.addAll(keys);
|
||||
}
|
||||
|
||||
// Verify the SD-JWT token cryptographically
|
||||
// Pass presentation requirements to enforce that the presented token meets them
|
||||
sdJwtVP.getSdJwtVerificationContext()
|
||||
.verifyPresentation(
|
||||
issuerVerifyingKeys,
|
||||
issuerSignedJwtVerificationOpts,
|
||||
keyBindingJwtVerificationOpts,
|
||||
presentationRequirements
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.keycloak.common.VerificationException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A simple presentation definition of the kind of credential expected.
|
||||
*
|
||||
* <p>
|
||||
* The credential's type and required claims are configured using regex patterns.
|
||||
* The values of these fields are JSON-ified prior to matching the regex pattern.
|
||||
* </p>
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class SimplePresentationDefinition implements PresentationRequirements {
|
||||
|
||||
private final Map<String, Pattern> requirements;
|
||||
|
||||
public SimplePresentationDefinition(Map<String, Pattern> requirements) {
|
||||
this.requirements = requirements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided JSON payload satisfies all required field patterns.
|
||||
*
|
||||
* <p>
|
||||
* For each required field, the corresponding JSON field value in the disclosed Issuer-signed JWT's payload
|
||||
* is matched against the associated regex pattern. If any required field is missing or does not match the
|
||||
* pattern, a {@link VerificationException} is thrown.
|
||||
* </p>
|
||||
*
|
||||
* @param disclosedPayload The fully disclosed Issuer-signed JWT of the presented token.
|
||||
* @throws VerificationException If any required field is missing or fails the pattern check.
|
||||
*/
|
||||
@Override
|
||||
public void checkIfSatisfiedBy(JsonNode disclosedPayload) throws VerificationException {
|
||||
for (Map.Entry<String, Pattern> requirement : requirements.entrySet()) {
|
||||
String field = requirement.getKey();
|
||||
Pattern pattern = requirement.getValue();
|
||||
|
||||
// Retrieve the value of the required field from the payload
|
||||
JsonNode presented = disclosedPayload.get(field);
|
||||
|
||||
// Check if the required field is present in the payload
|
||||
if (presented == null || presented.isNull()) {
|
||||
throw new VerificationException(
|
||||
String.format("A required field was not presented: `%s`", field)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the JSON representation of the field's value
|
||||
String json = presented.toString();
|
||||
|
||||
// Match the field value against the configured regex pattern
|
||||
Matcher matcher = pattern.matcher(json);
|
||||
if (!matcher.matches()) {
|
||||
throw new VerificationException(String.format(
|
||||
"Pattern matching failed for required field: `%s`. Expected pattern: /%s/, but got: %s",
|
||||
field, pattern.pattern(), json
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private final Map<String, Pattern> requirements = new HashMap<>();
|
||||
|
||||
public Builder addClaimRequirement(String field, String regexPattern) {
|
||||
this.requirements.put(field, Pattern.compile(regexPattern));
|
||||
return this;
|
||||
}
|
||||
|
||||
public SimplePresentationDefinition build() {
|
||||
return new SimplePresentationDefinition(requirements);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A trusted Issuer for running SD-JWT VP verification.
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class StaticTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
|
||||
|
||||
private final List<SignatureVerifierContext> signatureVerifierContexts;
|
||||
|
||||
public StaticTrustedSdJwtIssuer(List<SignatureVerifierContext> signatureVerifierContexts) {
|
||||
this.signatureVerifierContexts = signatureVerifierContexts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT) {
|
||||
return signatureVerifierContexts;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A trusted Issuer for running SD-JWT VP verification.
|
||||
*
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public interface TrustedSdJwtIssuer {
|
||||
|
||||
/**
|
||||
* Resolves potential verifying keys to validate the Issuer-signed JWT.
|
||||
* The method ensures that the resolved public keys can be trusted.
|
||||
*
|
||||
* @param issuerSignedJWT The Issuer-signed JWT to validate.
|
||||
* @return trusted verifying keys
|
||||
* @throws VerificationException if no trustworthy verifying key could be resolved
|
||||
*/
|
||||
List<SignatureVerifierContext> resolveIssuerVerifyingKeys(IssuerSignedJWT issuerSignedJWT)
|
||||
throws VerificationException;
|
||||
}
|
|
@ -30,6 +30,7 @@ import java.util.Set;
|
|||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.crypto.SignatureSignerContext;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.SdJwt;
|
||||
|
@ -54,6 +55,7 @@ public class SdJwtVP {
|
|||
private final String hashAlgorithm;
|
||||
|
||||
private final Optional<KeyBindingJWT> keyBindingJWT;
|
||||
private final SdJwtVerificationContext sdJwtVerificationContext;
|
||||
|
||||
public Map<String, ArrayNode> getClaims() {
|
||||
return claims;
|
||||
|
@ -98,6 +100,14 @@ public class SdJwtVP {
|
|||
this.recursiveDigests = Collections.unmodifiableMap(recursiveDigests);
|
||||
this.ghostDigests = Collections.unmodifiableList(ghostDigests);
|
||||
this.keyBindingJWT = keyBindingJWT;
|
||||
|
||||
// Instantiate context for verification
|
||||
this.sdJwtVerificationContext = new SdJwtVerificationContext(
|
||||
this.sdJwtVpString,
|
||||
this.issuerSignedJWT,
|
||||
this.disclosures,
|
||||
this.keyBindingJWT.orElse(null)
|
||||
);
|
||||
}
|
||||
|
||||
public static SdJwtVP of(String sdJwtString) {
|
||||
|
@ -217,21 +227,33 @@ public class SdJwtVP {
|
|||
/**
|
||||
* Verifies SD-JWT presentation.
|
||||
*
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the verification. A verifier must be specified
|
||||
* for validating the Issuer-signed JWT. The caller is responsible for
|
||||
* establishing trust in that associated public keys belong to the
|
||||
* intended issuer.
|
||||
* @param issuerVerifyingKeys Verifying keys for validating the Issuer-signed JWT. The caller
|
||||
* is responsible for establishing trust in that the keys belong
|
||||
* to the intended issuer.
|
||||
* @param issuerSignedJwtVerificationOpts Options to parameterize the Issuer-Signed JWT verification.
|
||||
* @param keyBindingJwtVerificationOpts Options to parameterize the Key Binding JWT verification.
|
||||
* Must, among others, specify the Verifier's policy whether
|
||||
* to check Key Binding.
|
||||
* @throws VerificationException if verification failed
|
||||
*/
|
||||
public void verify(
|
||||
List<SignatureVerifierContext> issuerVerifyingKeys,
|
||||
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts,
|
||||
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
|
||||
) throws VerificationException {
|
||||
new SdJwtVerificationContext(sdJwtVpString, issuerSignedJWT, disclosures, keyBindingJWT.orElse(null))
|
||||
.verifyPresentation(issuerSignedJwtVerificationOpts, keyBindingJwtVerificationOpts);
|
||||
sdJwtVerificationContext.verifyPresentation(
|
||||
issuerVerifyingKeys,
|
||||
issuerSignedJwtVerificationOpts,
|
||||
keyBindingJwtVerificationOpts,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve verification context for advanced scenarios.
|
||||
*/
|
||||
public SdJwtVerificationContext getSdJwtVerificationContext() {
|
||||
return sdJwtVerificationContext;
|
||||
}
|
||||
|
||||
// Recursively searches the node with the given value.
|
||||
|
|
|
@ -23,14 +23,16 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.rule.CryptoInitRule;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.keycloak.crypto.SignatureSignerContext;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.endsWith;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
@ -57,38 +59,61 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
@Test
|
||||
public void testSdJwtVerification_FlatSdJwt() throws VerificationException {
|
||||
for (String hashAlg : Arrays.asList(new String[]{"sha-256", "sha-384", "sha-512"})) {
|
||||
for (String hashAlg : Arrays.asList("sha-256", "sha-384", "sha-512")) {
|
||||
SdJwt sdJwt = exampleFlatSdJwtV1()
|
||||
.withHashAlgorithm(hashAlg)
|
||||
.build();
|
||||
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSdJwtVerification_EnforceIdempotence() throws VerificationException {
|
||||
SdJwt sdJwt = exampleFlatSdJwtV1().build();
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
SdJwt sdJwt = exampleFlatSdJwtV1().build();
|
||||
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSdJwtVerification_SdJwtWithUndisclosedNestedFields() throws VerificationException {
|
||||
SdJwt sdJwt = exampleSdJwtWithUndisclosedNestedFieldsV1().build();
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSdJwtVerification_SdJwtWithUndisclosedArrayElements() throws Exception {
|
||||
SdJwt sdJwt = exampleSdJwtWithUndisclosedArrayElementsV1().build();
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSdJwtVerification_RecursiveSdJwt() throws Exception {
|
||||
SdJwt sdJwt = exampleRecursiveSdJwtV1().build();
|
||||
sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build());
|
||||
|
||||
sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -99,7 +124,10 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Unexpected or insecure hash algorithm: sha-224", exception.getMessage());
|
||||
|
@ -110,13 +138,13 @@ public abstract class SdJwtVerificationTest {
|
|||
SdJwt sdJwt = exampleFlatSdJwtV1().build();
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.withVerifier(testSettings.holderVerifierContext) // wrong verifier
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
Collections.singletonList(testSettings.holderVerifierContext), // wrong verifier
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(exception.getMessage(), is("Invalid Issuer-Signed JWT"));
|
||||
assertThat(exception.getCause().getMessage(), endsWith("Invalid jws signature"));
|
||||
assertThat(exception.getMessage(), is("Invalid Issuer-Signed JWT: Signature could not be verified"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -135,12 +163,15 @@ public abstract class SdJwtVerificationTest {
|
|||
.withUndisclosedClaim("exp", "eluV5Og3gSNII8EYnsxA_A")
|
||||
.build()).build();
|
||||
|
||||
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
|
||||
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateExpirationClaim(true)
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateExpirationClaim(true)
|
||||
.build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
|
||||
|
@ -166,12 +197,15 @@ public abstract class SdJwtVerificationTest {
|
|||
SdJwt sdJwtV1 = exampleFlatSdJwtV2(claimSet1, disclosureSpec).build();
|
||||
SdJwt sdJwtV2 = exampleFlatSdJwtV2(claimSet2, disclosureSpec).build();
|
||||
|
||||
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
|
||||
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateExpirationClaim(true)
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateExpirationClaim(true)
|
||||
.build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
|
||||
|
@ -195,12 +229,15 @@ public abstract class SdJwtVerificationTest {
|
|||
.withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
|
||||
.build()).build();
|
||||
|
||||
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
|
||||
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateIssuedAtClaim(true)
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateIssuedAtClaim(true)
|
||||
.build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Issuer-Signed JWT: Invalid `iat` claim", exception.getMessage());
|
||||
|
@ -224,12 +261,15 @@ public abstract class SdJwtVerificationTest {
|
|||
.withUndisclosedClaim("iat", "eluV5Og3gSNII8EYnsxA_A")
|
||||
.build()).build();
|
||||
|
||||
for (SdJwt sdJwt : Arrays.asList(new SdJwt[]{sdJwtV1, sdJwtV2})) {
|
||||
for (SdJwt sdJwt : Arrays.asList(sdJwtV1, sdJwtV2)) {
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateNotBeforeClaim(true)
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts()
|
||||
.withValidateNotBeforeClaim(true)
|
||||
.build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Issuer-Signed JWT: Invalid `nbf` claim", exception.getMessage());
|
||||
|
@ -247,8 +287,10 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts()
|
||||
.build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Unexpected non-string element inside _sd array: 123", exception.getMessage());
|
||||
|
@ -256,7 +298,7 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
@Test
|
||||
public void sdJwtVerificationShouldFail_IfForbiddenClaimNames() {
|
||||
for (String forbiddenClaimName : Arrays.asList(new String[]{"_sd", "..."})) {
|
||||
for (String forbiddenClaimName : Arrays.asList("_sd", "...")) {
|
||||
ObjectNode claimSet = mapper.createObjectNode();
|
||||
claimSet.put(forbiddenClaimName, "Value");
|
||||
|
||||
|
@ -266,7 +308,10 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("Disclosure claim name must not be '_sd' or '...'", exception.getMessage());
|
||||
|
@ -286,7 +331,10 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().startsWith("A digest was encountered more than once:"));
|
||||
|
@ -307,15 +355,21 @@ public abstract class SdJwtVerificationTest {
|
|||
|
||||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwt.verify(defaultIssuerSignedJwtVerificationOpts().build())
|
||||
() -> sdJwt.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build()
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals("A salt value was reused: " + salt, exception.getMessage());
|
||||
}
|
||||
|
||||
private List<SignatureVerifierContext> defaultIssuerVerifyingKeys() {
|
||||
return Collections.singletonList(testSettings.issuerVerifierContext);
|
||||
}
|
||||
|
||||
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
|
||||
return IssuerSignedJwtVerificationOpts.builder()
|
||||
.withVerifier(testSettings.issuerVerifierContext)
|
||||
.withValidateIssuedAtClaim(false)
|
||||
.withValidateExpirationClaim(false)
|
||||
.withValidateNotBeforeClaim(false);
|
||||
|
|
|
@ -0,0 +1,419 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.rule.CryptoInitRule;
|
||||
import org.keycloak.sdjwt.IssuerSignedJWT;
|
||||
import org.keycloak.sdjwt.SdJws;
|
||||
import org.keycloak.sdjwt.SdJwtUtils;
|
||||
import org.keycloak.sdjwt.TestUtils;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
|
||||
import java.rmi.UnknownHostException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.endsWith;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
|
||||
|
||||
@ClassRule
|
||||
public static CryptoInitRule cryptoInitRule = new CryptoInitRule();
|
||||
|
||||
@Test
|
||||
public void shouldResolveIssuerVerifyingKeys() throws Exception {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUri, mockHttpDataFetcher());
|
||||
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt();
|
||||
List<SignatureVerifierContext> keys = trustedIssuer
|
||||
.resolveIssuerVerifyingKeys(issuerSignedJWT);
|
||||
|
||||
// There three keys exposed on the metadata endpoint.
|
||||
assertEquals(3, keys.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldResolveKeys_WhenIssuerTrustedOnRegexPattern() throws Exception {
|
||||
Pattern issuerUriRegex = Pattern.compile("https://.*\\.example\\.com");
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUriRegex, mockHttpDataFetcher());
|
||||
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt();
|
||||
trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldResolveKeys_WhenJwtSpecifiesKid() throws VerificationException, JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUri, mockHttpDataFetcher());
|
||||
|
||||
// This JWT specifies a key ID in its header
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
|
||||
|
||||
// Act
|
||||
List<SignatureVerifierContext> keys = trustedIssuer
|
||||
.resolveIssuerVerifyingKeys(issuerSignedJWT);
|
||||
|
||||
// Despite three keys exposed on the metadata endpoint,
|
||||
// only the key matching the JWT's kid is resolved.
|
||||
assertEquals(1, keys.size());
|
||||
assertEquals("doc-signer-05-25-2022", keys.get(0).getKid());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectNonHttpsIssuerURIs() {
|
||||
String issuerUri = "http://issuer.example.com"; // not https
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> new JwtVcMetadataTrustedSdJwtIssuer(issuerUri, mockHttpDataFetcher())
|
||||
);
|
||||
|
||||
assertThat(exception.getMessage(), endsWith("HTTPS URI required to retrieve JWT VC Issuer Metadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectNonHttpsIssuerURIs_EvenIfIssuerBuiltWithRegexPattern() throws JsonProcessingException {
|
||||
Pattern issuerUriRegex = Pattern.compile(".*\\.example\\.com");
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUriRegex, mockHttpDataFetcher());
|
||||
|
||||
String issuerUri = "http://issuer.example.com"; // not https
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwtWithIssuer(issuerUri);
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
|
||||
);
|
||||
|
||||
assertThat(exception.getMessage(), endsWith("HTTPS URI required to retrieve JWT VC Issuer Metadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectJwtsWithUnexpectedIssuerClaims() throws JsonProcessingException {
|
||||
String regex = "https://.*\\.example\\.com";
|
||||
Pattern issuerUriRegex = Pattern.compile(regex);
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUriRegex, mockHttpDataFetcher());
|
||||
|
||||
String issuerUri = "https://trial.authlete.com"; // does not match the regex above
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwtWithIssuer(issuerUri);
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
|
||||
);
|
||||
|
||||
assertThat(exception.getMessage(),
|
||||
endsWith(String.format("Unexpected Issuer URI claim. Expected=/%s/, Got=%s", regex, issuerUri)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnInvalidJwkExposed() throws JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
metadata.set("jwks", SdJwtUtils.mapper.readTree(
|
||||
"{\n" +
|
||||
" \"keys\": [\n" +
|
||||
" {\n" +
|
||||
" \"kty\": \"EC\",\n" +
|
||||
" \"x\": \"invalid\",\n" +
|
||||
" \"y\": \"invalid\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
"}"
|
||||
));
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
|
||||
"A potential JWK was retrieved but found invalid",
|
||||
"Unsupported or invalid JWK"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnUnexpectedIssuerExposed() throws JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", "https://another-issuer.example.com");
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
|
||||
"Unexpected metadata's issuer",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnMalformedJwtVcMetadataExposed() throws JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
// This is malformed because the issuer must be a string
|
||||
metadata.set("issuer", SdJwtUtils.mapper.readTree("{}"));
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
|
||||
"Failed to parse exposed JWT VC Metadata",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnMalformedJwksExposed() throws JsonProcessingException {
|
||||
List<JsonNode> malformedJwks = Arrays.asList(
|
||||
SdJwtUtils.mapper.readTree("[]"),
|
||||
SdJwtUtils.mapper.readTree("{\"keys\": {}}")
|
||||
);
|
||||
|
||||
for (JsonNode jwks : malformedJwks) {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
metadata.put("jwks_uri", "https://issuer.example.com/api/vci/jwks");
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
|
||||
"Failed to parse exposed JWKS",
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnMissingJwksOnMetadataEndpoint() throws JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
// There are no means to resolve JWKS with these metadata
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadata(issuerUri, metadata),
|
||||
String.format("Could not resolve issuer JWKs with URI: %s", issuerUri),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnEmptyPublishedJwkList() throws Exception {
|
||||
// This JWKS to publish has an empty list of keys, which is unexpected.
|
||||
JsonNode jwks = SdJwtUtils.mapper.readTree("{\"keys\": []}");
|
||||
|
||||
// JWT VC Metadata
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
metadata.set("jwks", jwks);
|
||||
|
||||
// Act and assert
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
|
||||
String.format("Issuer JWKs were unexpectedly resolved to an empty list. Issuer URI: %s", issuerUri),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnNoPublishedJwkMatchingJwtKid() throws Exception {
|
||||
// Alter kid fields of all JWKs to publish, so as none is a match
|
||||
JsonNode jwks = exampleJwks();
|
||||
for (JsonNode jwk : jwks.get("keys")) {
|
||||
((ObjectNode) jwk).put("kid", jwk.get("kid").asText() + "-wont-match");
|
||||
}
|
||||
|
||||
// JWT VC Metadata
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
metadata.set("jwks", jwks);
|
||||
|
||||
// This JWT specifies a key ID in its header
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
|
||||
String kid = issuerSignedJWT.getHeader().getKeyId();
|
||||
|
||||
// Act and assert
|
||||
genericTestShouldFail(
|
||||
issuerSignedJWT,
|
||||
mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, jwks),
|
||||
String.format("No published JWK was found to match kid: %s", kid),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnPublishedJwksWithDuplicateKid() throws JsonProcessingException {
|
||||
// This JWT specifies a key ID in its header
|
||||
|
||||
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb--explicit-kid.txt");
|
||||
|
||||
// Set the same kid to all JWKs to publish, which is problematic
|
||||
|
||||
String kid = issuerSignedJWT.getHeader().getKeyId();
|
||||
JsonNode jwks = exampleJwks();
|
||||
for (JsonNode jwk : jwks.get("keys")) {
|
||||
((ObjectNode) jwk).put("kid", kid);
|
||||
}
|
||||
|
||||
// JWT VC Metadata
|
||||
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
metadata.set("jwks", jwks);
|
||||
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerUri, mockHttpDataFetcherWithMetadata(issuerUri, metadata));
|
||||
|
||||
// Act and assert
|
||||
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
|
||||
);
|
||||
|
||||
assertThat(exception.getMessage(),
|
||||
endsWith(String.format("Cannot choose between multiple exposed JWKs with same kid: %s", kid)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailOnIOErrorWhileFetchingMetadata() throws JsonProcessingException {
|
||||
String issuerUri = "https://issuer.example.com";
|
||||
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", issuerUri);
|
||||
|
||||
HttpDataFetcher mockFetcher = mockHttpDataFetcherWithMetadata(
|
||||
// HTTP can only mock an unrelated issuer
|
||||
"https://another-issuer.example.com",
|
||||
metadata
|
||||
);
|
||||
|
||||
genericTestShouldFail(
|
||||
exampleIssuerSignedJwt(),
|
||||
mockFetcher,
|
||||
String.format("Could not fetch data from URI: %s", issuerUri),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void genericTestShouldFail(
|
||||
IssuerSignedJWT issuerSignedJWT,
|
||||
HttpDataFetcher mockFetcher,
|
||||
String errorMessage,
|
||||
String causeErrorMessage
|
||||
) {
|
||||
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
|
||||
issuerSignedJWT.getPayload().get(SdJws.CLAIM_NAME_ISSUER).asText(),
|
||||
mockFetcher
|
||||
);
|
||||
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> trustedIssuer.resolveIssuerVerifyingKeys(issuerSignedJWT)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains(errorMessage));
|
||||
if (causeErrorMessage != null) {
|
||||
assertTrue(exception.getCause().getMessage().contains(causeErrorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
private IssuerSignedJWT exampleIssuerSignedJwt() {
|
||||
return exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb.txt");
|
||||
}
|
||||
|
||||
private IssuerSignedJWT exampleIssuerSignedJwt(String sdJwtVector) {
|
||||
return exampleIssuerSignedJwt(sdJwtVector, "https://issuer.example.com");
|
||||
}
|
||||
|
||||
private IssuerSignedJWT exampleIssuerSignedJwtWithIssuer(String issuerUri) {
|
||||
return exampleIssuerSignedJwt("sdjwt/s20.1-sdjwt+kb.txt", issuerUri);
|
||||
}
|
||||
|
||||
private IssuerSignedJWT exampleIssuerSignedJwt(String sdJwtVector, String issuerUri) {
|
||||
String sdJwtVPString = TestUtils.readFileAsString(getClass(), sdJwtVector);
|
||||
IssuerSignedJWT issuerSignedJWT = SdJwtVP.of(sdJwtVPString).getIssuerSignedJWT();
|
||||
((ObjectNode) issuerSignedJWT.getPayload()).put("iss", issuerUri);
|
||||
return issuerSignedJWT;
|
||||
}
|
||||
|
||||
private JsonNode exampleJwks() throws JsonProcessingException {
|
||||
return SdJwtUtils.mapper.readTree(
|
||||
TestUtils.readFileAsString(getClass(),
|
||||
"sdjwt/s30.1-jwt-vc-metadata-jwks.json"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private HttpDataFetcher mockHttpDataFetcher() throws JsonProcessingException {
|
||||
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
|
||||
metadata.put("issuer", "https://issuer.example.com");
|
||||
metadata.put("jwks_uri", "https://issuer.example.com/api/vci/jwks");
|
||||
|
||||
return mockHttpDataFetcherWithMetadata(
|
||||
metadata.get("issuer").asText(),
|
||||
metadata
|
||||
);
|
||||
}
|
||||
|
||||
private HttpDataFetcher mockHttpDataFetcherWithMetadata(
|
||||
String issuer, JsonNode metadata
|
||||
) throws JsonProcessingException {
|
||||
return mockHttpDataFetcherWithMetadataAndJwks(issuer, metadata, exampleJwks());
|
||||
}
|
||||
|
||||
private HttpDataFetcher mockHttpDataFetcherWithMetadataAndJwks(
|
||||
String issuer, JsonNode metadata, JsonNode jwks
|
||||
) {
|
||||
return uri -> {
|
||||
if (!uri.startsWith(issuer)) {
|
||||
throw new UnknownHostException("Unavailable URI");
|
||||
}
|
||||
|
||||
if (uri.endsWith("/.well-known/jwt-vc-issuer")) {
|
||||
return metadata;
|
||||
} else if (uri.endsWith("/api/vci/jwks")) {
|
||||
return jwks;
|
||||
} else {
|
||||
throw new UnknownHostException("Unavailable URI");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.rule.CryptoInitRule;
|
||||
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.TestSettings;
|
||||
import org.keycloak.sdjwt.TestUtils;
|
||||
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public abstract class SdJwtPresentationConsumerTest {
|
||||
|
||||
@ClassRule
|
||||
public static CryptoInitRule cryptoInitRule = new CryptoInitRule();
|
||||
|
||||
SdJwtPresentationConsumer sdJwtPresentationConsumer = new SdJwtPresentationConsumer();
|
||||
static TestSettings testSettings = TestSettings.getInstance();
|
||||
|
||||
@Test
|
||||
public void shouldVerifySdJwtPresentation() throws VerificationException {
|
||||
sdJwtPresentationConsumer.verifySdJwtPresentation(
|
||||
exampleSdJwtVP(),
|
||||
examplePresentationRequirements(),
|
||||
exampleTrustedSdJwtIssuers(),
|
||||
defaultIssuerSignedJwtVerificationOpts(),
|
||||
defaultKeyBindingJwtVerificationOpts()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFail_IfPresentationRequirementsNotMet() {
|
||||
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
|
||||
.addClaimRequirement("vct", ".*diploma.*")
|
||||
.build();
|
||||
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> sdJwtPresentationConsumer.verifySdJwtPresentation(
|
||||
exampleSdJwtVP(),
|
||||
definition,
|
||||
exampleTrustedSdJwtIssuers(),
|
||||
defaultIssuerSignedJwtVerificationOpts(),
|
||||
defaultKeyBindingJwtVerificationOpts()
|
||||
)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage()
|
||||
.contains("A required field was not presented: `vct`"));
|
||||
}
|
||||
|
||||
private SdJwtVP exampleSdJwtVP() {
|
||||
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt");
|
||||
return SdJwtVP.of(sdJwtVPString);
|
||||
}
|
||||
|
||||
private PresentationRequirements examplePresentationRequirements() {
|
||||
return SimplePresentationDefinition.builder()
|
||||
.addClaimRequirement("sub", "\"user_[0-9]+\"")
|
||||
.addClaimRequirement("given_name", ".*")
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<TrustedSdJwtIssuer> exampleTrustedSdJwtIssuers() {
|
||||
return Arrays.asList(
|
||||
new StaticTrustedSdJwtIssuer(
|
||||
Collections.singletonList(testSettings.holderVerifierContext)
|
||||
),
|
||||
new StaticTrustedSdJwtIssuer(
|
||||
Collections.singletonList(testSettings.issuerVerifierContext)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private IssuerSignedJwtVerificationOpts defaultIssuerSignedJwtVerificationOpts() {
|
||||
return IssuerSignedJwtVerificationOpts.builder()
|
||||
.withValidateIssuedAtClaim(false)
|
||||
.withValidateNotBeforeClaim(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private KeyBindingJwtVerificationOpts defaultKeyBindingJwtVerificationOpts() {
|
||||
return KeyBindingJwtVerificationOpts.builder()
|
||||
.withKeyBindingRequired(true)
|
||||
.withAllowedMaxAge(Integer.MAX_VALUE)
|
||||
.withNonce("1234567890")
|
||||
.withAud("https://verifier.example.org")
|
||||
.withValidateExpirationClaim(false)
|
||||
.withValidateNotBeforeClaim(false)
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright 2024 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.sdjwt.consumer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.sdjwt.SdJwtUtils;
|
||||
import org.keycloak.sdjwt.TestUtils;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.startsWith;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class SimplePresentationDefinitionTest {
|
||||
|
||||
private final ObjectMapper mapper = SdJwtUtils.mapper;
|
||||
|
||||
@Test
|
||||
public void testCheckIfSatisfiedBy() throws VerificationException, JsonProcessingException {
|
||||
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
|
||||
.addClaimRequirement("vct", ".*identity_credential.*")
|
||||
.addClaimRequirement("given_name", "\"John\"")
|
||||
.addClaimRequirement("cat", "123")
|
||||
.addClaimRequirement("addr", ".*\"(Douala|Berlin)\".*")
|
||||
.addClaimRequirement("colors", "\\[\"red\",.*")
|
||||
.build();
|
||||
|
||||
definition.checkIfSatisfiedBy(exampleDisclosedPayload());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckIfSatisfiedBy_shouldFailOnRequiredFieldMissing() {
|
||||
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
|
||||
.addClaimRequirement("family_name", ".*")
|
||||
.build();
|
||||
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> definition.checkIfSatisfiedBy(exampleDisclosedPayload()));
|
||||
|
||||
assertEquals("A required field was not presented: `family_name`", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckIfSatisfiedBy_shouldFailOnNonMatchingPattern() {
|
||||
SimplePresentationDefinition definition = SimplePresentationDefinition.builder()
|
||||
.addClaimRequirement("vct", ".*diploma.*")
|
||||
.build();
|
||||
|
||||
VerificationException exception = assertThrows(VerificationException.class,
|
||||
() -> definition.checkIfSatisfiedBy(exampleDisclosedPayload()));
|
||||
|
||||
assertThat(exception.getMessage(), startsWith("Pattern matching failed for required field"));
|
||||
}
|
||||
|
||||
private JsonNode exampleDisclosedPayload() throws JsonProcessingException {
|
||||
String content = TestUtils.readFileAsString(getClass(),
|
||||
"sdjwt/s7.4-sample-disclosed-issuer-payload.json");
|
||||
return mapper.readTree(content);
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.rule.CryptoInitRule;
|
||||
import org.keycloak.sdjwt.IssuerSignedJwtVerificationOpts;
|
||||
import org.keycloak.sdjwt.SdJwt;
|
||||
|
@ -34,6 +35,7 @@ import org.keycloak.sdjwt.vp.SdJwtVP;
|
|||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.containsString;
|
||||
|
@ -63,6 +65,7 @@ public abstract class SdJwtVPVerificationTest {
|
|||
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
|
||||
|
||||
sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
defaultKeyBindingJwtVerificationOpts().build()
|
||||
);
|
||||
|
@ -70,15 +73,17 @@ public abstract class SdJwtVPVerificationTest {
|
|||
|
||||
@Test
|
||||
public void testVerif_s20_8_sdjwt_with_kb__AltCnfCurves() throws VerificationException {
|
||||
List<String> entries = Arrays.asList(new String[]{
|
||||
"sdjwt/s20.8-sdjwt+kb--es384.txt", "sdjwt/s20.8-sdjwt+kb--es512.txt"
|
||||
});
|
||||
List<String> entries = Arrays.asList(
|
||||
"sdjwt/s20.8-sdjwt+kb--es384.txt",
|
||||
"sdjwt/s20.8-sdjwt+kb--es512.txt"
|
||||
);
|
||||
|
||||
for (String entry : entries) {
|
||||
String sdJwtVPString = TestUtils.readFileAsString(getClass(), entry);
|
||||
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
|
||||
|
||||
sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
defaultKeyBindingJwtVerificationOpts().build()
|
||||
);
|
||||
|
@ -87,18 +92,19 @@ public abstract class SdJwtVPVerificationTest {
|
|||
|
||||
@Test
|
||||
public void testVerif_s20_8_sdjwt_with_kb__CnfRSA() throws VerificationException {
|
||||
List<String> entries = Arrays.asList(new String[]{
|
||||
List<String> entries = Arrays.asList(
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-rs256.txt",
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps256.txt",
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps384.txt",
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-rsa-ps512.txt"
|
||||
});
|
||||
);
|
||||
|
||||
for (String entry : entries) {
|
||||
String sdJwtVPString = TestUtils.readFileAsString(getClass(), entry);
|
||||
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
|
||||
|
||||
sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
defaultKeyBindingJwtVerificationOpts().build()
|
||||
);
|
||||
|
@ -111,6 +117,7 @@ public abstract class SdJwtVPVerificationTest {
|
|||
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
|
||||
|
||||
sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
defaultKeyBindingJwtVerificationOpts()
|
||||
.withKeyBindingRequired(false)
|
||||
|
@ -327,6 +334,7 @@ public abstract class SdJwtVPVerificationTest {
|
|||
UnsupportedOperationException exception = assertThrows(
|
||||
UnsupportedOperationException.class,
|
||||
() -> sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
defaultKeyBindingJwtVerificationOpts().build()
|
||||
)
|
||||
|
@ -341,8 +349,8 @@ public abstract class SdJwtVPVerificationTest {
|
|||
// The cnf/jwk object has an unrecognized key type
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-jwk-is-malformed.txt",
|
||||
defaultKeyBindingJwtVerificationOpts().build(),
|
||||
"Malformed or unsupported cnf/jwk claim",
|
||||
null
|
||||
"Could not process cnf/jwk",
|
||||
"Unsupported or invalid JWK"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -352,8 +360,8 @@ public abstract class SdJwtVPVerificationTest {
|
|||
// HMAC cnf/jwk parsing is not supported
|
||||
"sdjwt/s20.8-sdjwt+kb--cnf-hmac.txt",
|
||||
defaultKeyBindingJwtVerificationOpts().build(),
|
||||
"Malformed or unsupported cnf/jwk claim",
|
||||
null
|
||||
"Could not process cnf/jwk",
|
||||
"Unsupported or invalid JWK"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -369,6 +377,7 @@ public abstract class SdJwtVPVerificationTest {
|
|||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
keyBindingJwtVerificationOpts
|
||||
)
|
||||
|
@ -405,6 +414,7 @@ public abstract class SdJwtVPVerificationTest {
|
|||
VerificationException exception = assertThrows(
|
||||
VerificationException.class,
|
||||
() -> sdJwtVP.verify(
|
||||
defaultIssuerVerifyingKeys(),
|
||||
defaultIssuerSignedJwtVerificationOpts().build(),
|
||||
keyBindingJwtVerificationOpts
|
||||
)
|
||||
|
@ -416,9 +426,12 @@ public abstract class SdJwtVPVerificationTest {
|
|||
}
|
||||
}
|
||||
|
||||
private List<SignatureVerifierContext> defaultIssuerVerifyingKeys() {
|
||||
return Collections.singletonList(testSettings.issuerVerifierContext);
|
||||
}
|
||||
|
||||
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
|
||||
return IssuerSignedJwtVerificationOpts.builder()
|
||||
.withVerifier(testSettings.issuerVerifierContext)
|
||||
.withValidateIssuedAtClaim(false)
|
||||
.withValidateNotBeforeClaim(false);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
eyJhbGciOiJFUzI1NiIsInR5cCI6ImV4YW1wbGUrc2Qtand0Iiwia2lkIjoiZG9jLXNpZ25lci0wNS0yNS0yMDIyIn0.eyJfc2QiOlsiQ3JRZTdTNWtxQkFIdC1uTVlYZ2M2YmR0MlNINWFUWTFzVV9NLVBna2pQSSIsIkp6WWpINHN2bGlIMFIzUHlFTWZlWnU2SnQ2OXU1cWVoWm83RjdFUFlsU0UiLCJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwiVEdmNG9MYmd3ZDVKUWFIeUtWUVpVOVVkR0UwdzVydERzclp6ZlVhb21MbyIsIlhRXzNrUEt0MVh5WDdLQU5rcVZSNnlaMlZhNU5yUEl2UFlieU12UktCTU0iLCJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwiZ2JPc0k0RWRxMngyS3ctdzV3UEV6YWtvYjloVjFjUkQwQVROM29RTDlKTSIsImpzdTl5VnVsd1FRbGhGbE1fM0psek1hU0Z6Z2xoUUcwRHBmYXlRd0xVSzQiXSwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJpYXQiOjE2ODMwMDAwMDAsImV4cCI6MTg4MzAwMDAwMCwic3ViIjoidXNlcl80MiIsIm5hdGlvbmFsaXRpZXMiOlt7Ii4uLiI6InBGbmRqa1pfVkN6bXlUYTZVamxabzNkaC1rbzhhSUtRYzlEbEd6aGFWWW8ifSx7Ii4uLiI6IjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sIl9zZF9hbGciOiJzaGEtMjU2IiwiY25mIjp7Imp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlRDQUVSMTladnUzT0hGNGo0VzR2ZlNWb0hJUDFJTGlsRGxzN3ZDZUdlbWMiLCJ5IjoiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.N0xUjkyxK6q-uvDF0bLpOSq8XI-QXZ9iI5U4w4GSx9NwDZQfg4P9SffgjQ11LwZKKfLprNernp53-oRBWaOuDA
|
||||
|
||||
~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0
|
||||
~
|
||||
|
||||
eyJraWQiOiJob2xkZXIiLCJ0eXAiOiJrYitqd3QiLCJhbGciOiJFUzI1NiJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAiLCJhdWQiOiJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwiaWF0IjoxNzAyMzE1Njc5LCJzZF9oYXNoIjoiSEtwV1JKMmtqaXluSjBBbDlNTFJ5dmFjSS1HSVpmMHN5SUVvUnB2VktESSJ9.YFBWGvdAq8UIz7Y3b2lVMaQAFCkS02qkClGOPsn9qE-xDOgqT6VYx2D9-nSAU69dvkTdq6ynPMutlCYNtvtZ6w
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"keys": [
|
||||
{
|
||||
"kid": "doc-signer-05-25-2022",
|
||||
"kty": "EC",
|
||||
"d": "Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g",
|
||||
"crv": "P-256",
|
||||
"x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ",
|
||||
"y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8"
|
||||
},
|
||||
{
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"kid": "J1FwJP87C6-QN_WSIOmJAQc6n5CQ_bZdaFJ5GDnW1Rk",
|
||||
"x5c": [
|
||||
"MIIBXTCCAQSgAwIBAgIGAYyR2cIZMAoGCCqGSM49BAMCMDYxNDAyBgNVBAMMK0oxRndKUDg3QzYtUU5fV1NJT21KQVFjNm41Q1FfYlpkYUZKNUdEblcxUmswHhcNMjMxMjIyMTQwNjU2WhcNMjQxMDE3MTQwNjU2WjA2MTQwMgYDVQQDDCtKMUZ3SlA4N0M2LVFOX1dTSU9tSkFRYzZuNUNRX2JaZGFGSjVHRG5XMVJrMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAopVeboJpYRycw1YKkkROXfCpKEKl9Y1YPFhOGj4xTg2UOunxTxSIVkT94qFVIuu1hkEoE2NxelZo3+yTFUODDAKBggqhkjOPQQDAgNHADBEAiBnFjScBcvERleLjMCu5NbxJKkNsa/gQhkXTfDmbq+T3gIgVazbsVdQvZgluc9nJYQxWlzXT9i6f+wgUKx0KCYbj3A="
|
||||
],
|
||||
"x": "AopVeboJpYRycw1YKkkROXfCpKEKl9Y1YPFhOGj4xTg",
|
||||
"y": "NlDrp8U8UiFZE_eKhVSLrtYZBKBNjcXpWaN_skxVDgw",
|
||||
"alg": "ES256"
|
||||
},
|
||||
{
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"kid": "ZbAAKwhynrqBnYlHdEkBIvNJFZZH_bRg1KIopKfZ6O8",
|
||||
"x": "eshMYyyoEsH_Eb85a7o76msXFPokfvNaeyY3u5qDm3M",
|
||||
"y": "q0lGMn_UXiWJdgJtSCNzh9zPC6s7qKqQMo4V1i-69jA",
|
||||
"alg": "ES256"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"vct": "https://credentials.example.com/identity_credential",
|
||||
"given_name": "John",
|
||||
"cat": 123,
|
||||
"addr": {"city": "Douala", "country": "CM"},
|
||||
"colors": ["red", "green"]
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright 2024 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.crypto.def.test.sdjwt;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Before;
|
||||
import org.keycloak.common.util.Environment;
|
||||
import org.keycloak.sdjwt.consumer.JwtVcMetadataTrustedSdJwtIssuerTest;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class DefaultCryptoJwtVcMetadataTrustedSdJwtIssuerTest extends JwtVcMetadataTrustedSdJwtIssuerTest {
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
// Run this test just if java is not in FIPS mode
|
||||
Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright 2024 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.crypto.def.test.sdjwt;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Before;
|
||||
import org.keycloak.common.util.Environment;
|
||||
import org.keycloak.sdjwt.consumer.SdJwtPresentationConsumerTest;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class DefaultCryptoSdJwtPresentationConsumerTest extends SdJwtPresentationConsumerTest {
|
||||
|
||||
@Before
|
||||
public void before() {
|
||||
// Run this test just if java is not in FIPS mode
|
||||
Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright 2024 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.crypto.elytron.test.sdjwt;
|
||||
|
||||
import org.keycloak.sdjwt.consumer.JwtVcMetadataTrustedSdJwtIssuerTest;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class ElytronCryptoJwtVcMetadataTrustedSdJwtIssuerTest extends JwtVcMetadataTrustedSdJwtIssuerTest {
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright 2024 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.crypto.elytron.test.sdjwt;
|
||||
|
||||
import org.keycloak.sdjwt.consumer.SdJwtPresentationConsumerTest;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
|
||||
*/
|
||||
public class ElytronCryptoSdJwtPresentationConsumerTest extends SdJwtPresentationConsumerTest {
|
||||
}
|
Loading…
Reference in a new issue