mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
[OID4VCI] Add nonce endpoint (#39479)
fixes #39272 Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
This commit is contained in:
parent
192c7bed57
commit
193bee0c6e
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.oid4vci;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public final class Oid4VciConstants {
|
||||
|
||||
public static final String C_NONCE_LIFETIME_IN_SECONDS = "vc.c-nonce-lifetime-seconds";
|
||||
|
||||
private Oid4VciConstants() {}
|
||||
}
|
||||
@ -33,6 +33,7 @@ import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
@ -53,6 +54,8 @@ import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
|
||||
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner;
|
||||
@ -63,6 +66,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.NonceResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
@ -118,6 +122,7 @@ public class OID4VCIssuerEndpoint {
|
||||
private static final String CODE_LIFESPAN_REALM_ATTRIBUTE_KEY = "preAuthorizedCodeLifespanS";
|
||||
private static final int DEFAULT_CODE_LIFESPAN_S = 30;
|
||||
|
||||
public static final String NONCE_PATH = "nonce";
|
||||
public static final String CREDENTIAL_PATH = "credential";
|
||||
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
|
||||
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
|
||||
@ -198,6 +203,26 @@ public class OID4VCIssuerEndpoint {
|
||||
.collect(Collectors.toMap(CredentialBuilder::getSupportedFormat, component -> component));
|
||||
}
|
||||
|
||||
/**
|
||||
* the OpenId4VCI nonce-endpoint
|
||||
*
|
||||
* @return a short-lived c_nonce value that must be presented in key-bound proofs at the credential endpoint.
|
||||
* @see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0-15.html#name-nonce-endpoint
|
||||
* @see https://datatracker.ietf.org/doc/html/draft-demarco-nonce-endpoint#name-nonce-response
|
||||
*/
|
||||
@POST
|
||||
@Produces({MediaType.APPLICATION_JSON})
|
||||
@Path(NONCE_PATH)
|
||||
public Response getCNonce() {
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
NonceResponse nonceResponse = new NonceResponse();
|
||||
String sourceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
|
||||
String audience = OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
|
||||
String nonce = cNonceHandler.buildCNonce(List.of(audience), Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, sourceEndpoint));
|
||||
nonceResponse.setNonce(nonce);
|
||||
return Response.ok().header(HttpHeaders.CACHE_CONTROL, "no-store").entity(nonceResponse).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the URI to the OID4VCI compliant credentials offer
|
||||
*/
|
||||
|
||||
@ -71,9 +71,9 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
return new CredentialIssuer()
|
||||
.setCredentialIssuer(getIssuer(keycloakSession.getContext()))
|
||||
.setCredentialEndpoint(getCredentialsEndpoint(keycloakSession.getContext()))
|
||||
.setNonceEndpoint(getNonceEndpoint(keycloakSession.getContext()))
|
||||
.setCredentialsSupported(getSupportedCredentials(keycloakSession))
|
||||
.setAuthorizationServers(List.of(getIssuer(keycloakSession.getContext())));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -129,6 +129,14 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the nonce endpoint address
|
||||
*/
|
||||
public static String getNonceEndpoint(KeycloakContext context) {
|
||||
return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" +
|
||||
OID4VCIssuerEndpoint.NONCE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the credentials endpoint address
|
||||
*/
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public interface CNonceHandler extends Provider {
|
||||
|
||||
/**
|
||||
* used to build a cNonce in any style. For jwt-based cNonces we will additionally require the audience-values that
|
||||
* should be added into the cNonce
|
||||
*
|
||||
* @param audiences the audiences for jwt-based cNonces
|
||||
* @param additionalDetails additional attributes that might be required to build the cNonce and that are handler
|
||||
* specific
|
||||
* @return the cNonce in string representation
|
||||
*/
|
||||
public String buildCNonce(List<String> audiences, @Nullable Map<String, Object> additionalDetails);
|
||||
|
||||
/**
|
||||
* must verify the validity of a cNonce value that has been issued by the {@link #buildCNonce(List, Map)} method.
|
||||
*
|
||||
* @param cNonce the cNonce to validate
|
||||
* @param audiences the expected audiences for jwt-based cNonces
|
||||
* @param additionalDetails additional attributes that might be required to build the cNonce and that are handler
|
||||
* specific
|
||||
*/
|
||||
public void verifyCNonce(String cNonce, List<String> audiences, @Nullable Map<String, Object> additionalDetails) throws VerificationException;
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public interface CNonceHandlerFactory extends ProviderFactory<CNonceHandler> {
|
||||
|
||||
@Override
|
||||
default void init(Config.Scope config) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
@Override
|
||||
default void postInit(KeycloakSessionFactory factory) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class CNonceHandlerSpi implements Spi {
|
||||
|
||||
public static final String SPI_NAME = "oid4vci-c-nonce-spi";
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return SPI_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return CNonceHandler.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return CNonceHandlerFactory.class;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Base64;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.crypto.KeyUse;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.crypto.SignatureProvider;
|
||||
import org.keycloak.crypto.SignatureSignerContext;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.jose.jws.JWSBuilder;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.oid4vci.Oid4VciConstants;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.model.JwtCNonce;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.saml.RandomSecret;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class JwtCNonceHandler implements CNonceHandler {
|
||||
|
||||
public static final String SOURCE_ENDPOINT = "source_endpoint";
|
||||
|
||||
public static final int NONCE_DEFAULT_LENGTH = 50;
|
||||
|
||||
public static final int NONCE_LENGTH_RANDOM_OFFSET = 15;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtCNonceHandler.class);
|
||||
|
||||
private final KeycloakSession keycloakSession;
|
||||
|
||||
private final KeyWrapper signingKey;
|
||||
|
||||
public JwtCNonceHandler(KeycloakSession keycloakSession) {
|
||||
this.keycloakSession = keycloakSession;
|
||||
this.signingKey = selectSigningKey(keycloakSession.getContext().getRealm());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildCNonce(List<String> audiences, Map<String, Object> additionalDetails) {
|
||||
RealmModel realm = keycloakSession.getContext().getRealm();
|
||||
final String issuer = OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext());
|
||||
// TODO discussion about the attribute name to use
|
||||
final Integer nonceLifetimeMillis = realm.getAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, 60);
|
||||
audiences = Optional.ofNullable(audiences).orElseGet(Collections::emptyList);
|
||||
final Instant now = Instant.now();
|
||||
final long expiresAt = now.plus(nonceLifetimeMillis, ChronoUnit.SECONDS).getEpochSecond();
|
||||
final int nonceLength = NONCE_DEFAULT_LENGTH + new Random().nextInt(NONCE_LENGTH_RANDOM_OFFSET);
|
||||
// this generated value itself is basically just a salt-value for the generated token, which itself is the nonce.
|
||||
final String strongSalt = Base64.encodeBytes(RandomSecret.createRandomSecret(nonceLength));
|
||||
|
||||
JsonWebToken jwtCNonce = new JwtCNonce().salt(strongSalt)
|
||||
.issuer(issuer)
|
||||
.audience(audiences.toArray(String[]::new))
|
||||
.exp(expiresAt);
|
||||
Optional.ofNullable(additionalDetails).ifPresent(map -> {
|
||||
map.forEach(jwtCNonce::setOtherClaims);
|
||||
});
|
||||
|
||||
SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class,
|
||||
signingKey.getAlgorithm());
|
||||
SignatureSignerContext signatureSignerContext = signatureProvider.signer(signingKey);
|
||||
return new JWSBuilder().jsonContent(jwtCNonce).sign(signatureSignerContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void verifyCNonce(String cNonce, List<String> audiences, @Nullable Map<String, Object> additionalDetails)
|
||||
throws VerificationException {
|
||||
if (cNonce == null) {
|
||||
throw new VerificationException("c_nonce is required");
|
||||
}
|
||||
TokenVerifier<JsonWebToken> verifier = TokenVerifier.create(cNonce, JsonWebToken.class);
|
||||
KeycloakContext keycloakContext = keycloakSession.getContext();
|
||||
List<TokenVerifier.Predicate<JsonWebToken>> verifiers = //
|
||||
new ArrayList<>(List.of(jwt -> {
|
||||
String expectedIssuer = OID4VCIssuerWellKnownProvider.getIssuer(keycloakContext);
|
||||
if (!expectedIssuer.equals(jwt.getIssuer())) {
|
||||
String message = String.format(
|
||||
"c_nonce issuer did not match: %s(expected) != %s(actual)",
|
||||
expectedIssuer, jwt.getIssuer());
|
||||
throw new VerificationException(message);
|
||||
}
|
||||
return true;
|
||||
}, jwt -> {
|
||||
List<String> actualValue = Optional.ofNullable(jwt.getAudience())
|
||||
.map(Arrays::asList)
|
||||
.orElse(List.of());
|
||||
return checkAttributeEquality("aud", audiences, actualValue);
|
||||
},jwt -> {
|
||||
String salt = Optional.ofNullable(jwt.getOtherClaims())
|
||||
.map(m -> String.valueOf(m.get("salt")))
|
||||
.orElse(null);
|
||||
final int saltLength = Optional.ofNullable(salt).map(String::length)
|
||||
.orElse(0);
|
||||
if (saltLength < NONCE_DEFAULT_LENGTH){
|
||||
String message = String.format(
|
||||
"c_nonce-salt is not of expected length: %s(actual) < %s(expected)",
|
||||
saltLength, NONCE_DEFAULT_LENGTH);
|
||||
throw new VerificationException(message);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
jwt -> {
|
||||
Long exp = jwt.getExp();
|
||||
if (exp == null) {
|
||||
throw new VerificationException("c_nonce has no expiration time");
|
||||
}
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (exp < now) {
|
||||
String message = String.format(
|
||||
"c_nonce not valid: %s(exp) < %s(now)",
|
||||
exp,
|
||||
now);
|
||||
throw new VerificationException(message);
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
Optional.ofNullable(additionalDetails).ifPresent(map -> {
|
||||
map.forEach((key, object) -> {
|
||||
verifiers.add(jwt -> {
|
||||
Object actualValue = Optional.ofNullable(jwt.getOtherClaims())
|
||||
.map(claimMap -> claimMap.get(key))
|
||||
.orElse(null);
|
||||
return checkAttributeEquality(key, object, actualValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
verifier.withChecks(verifiers.toArray(new TokenVerifier.Predicate[0]));
|
||||
SignatureVerifierContext signatureVerifier = keycloakSession.getProvider(SignatureProvider.class,
|
||||
signingKey.getAlgorithm())
|
||||
.verifier(signingKey);
|
||||
verifier.verifierContext(signatureVerifier);
|
||||
verifier.verify(); // throws a VerificationException on failure
|
||||
}
|
||||
|
||||
protected boolean checkAttributeEquality(String key, Object object, Object actualValue) throws VerificationException {
|
||||
boolean isEqual = Objects.equals(object, actualValue);
|
||||
if (!isEqual) {
|
||||
String message = String.format(
|
||||
"c_nonce: expected '%s' to be equal to '%s' but actual value was '%s'",
|
||||
key,
|
||||
object,
|
||||
actualValue);
|
||||
throw new VerificationException(message);
|
||||
}
|
||||
return isEqual;
|
||||
}
|
||||
|
||||
protected KeyWrapper selectSigningKey(RealmModel realm) {
|
||||
KeyWrapper signingKey;
|
||||
try {
|
||||
signingKey = keycloakSession.keys().getActiveKey(realm, KeyUse.SIG, Algorithm.ES256);
|
||||
} catch (RuntimeException ex) {
|
||||
logger.debug("Failed to find active ES256 signing key for realm {}. Falling back to RSA...",
|
||||
realm.getName());
|
||||
logger.debug(ex.getMessage(), ex);
|
||||
// use RSA only as fallback since the preferred algorithm by OpenID4VC is elliptic curve
|
||||
signingKey = keycloakSession.keys().getActiveKey(realm, KeyUse.SIG, Algorithm.RS256);
|
||||
}
|
||||
return signingKey;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.keybinding;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class JwtCNonceHandlerFactory implements CNonceHandlerFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "oid4vci-jwt-c-nonce-builder";
|
||||
|
||||
@Override
|
||||
public CNonceHandler create(KeycloakSession session) {
|
||||
return new JwtCNonceHandler(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.jose.jws.JWSInputException;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
|
||||
@ -39,6 +40,8 @@ import org.keycloak.util.JsonSerialization;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@ -108,7 +111,7 @@ public class JwtProofValidator extends AbstractProofValidator {
|
||||
throw new VCIssuerException("No verifier configured for " + jwsHeader.getAlgorithm());
|
||||
}
|
||||
if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) {
|
||||
throw new VCIssuerException("Could not verify provided proof");
|
||||
throw new VCIssuerException("Could not verify signature of provided proof");
|
||||
}
|
||||
|
||||
return jwk;
|
||||
@ -183,7 +186,8 @@ public class JwtProofValidator extends AbstractProofValidator {
|
||||
});
|
||||
}
|
||||
|
||||
private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException {
|
||||
private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload)
|
||||
throws VCIssuerException, VerificationException {
|
||||
// azp is the id of the client, as mentioned in the access token used to request the credential.
|
||||
// Token provided from user is obtained with a clientId that support the oidc login protocol.
|
||||
// oid4vci client doesn't. But it is the client needed at the credential endpoint.
|
||||
@ -206,21 +210,11 @@ public class JwtProofValidator extends AbstractProofValidator {
|
||||
Optional.ofNullable(proofPayload.getIat())
|
||||
.orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided."));
|
||||
|
||||
// Check cNonce matches.
|
||||
// If the token endpoint provides a c_nonce, we would like this:
|
||||
// - stored in the access token
|
||||
// - having the same validity as the access token.
|
||||
Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce())
|
||||
.ifPresent(
|
||||
cNonce -> {
|
||||
Optional.ofNullable(proofPayload.getNonce())
|
||||
.filter(nonce -> Objects.equals(cNonce, nonce))
|
||||
.orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any."));
|
||||
|
||||
// We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager,
|
||||
// So no_op
|
||||
}
|
||||
);
|
||||
|
||||
KeycloakContext keycloakContext = keycloakSession.getContext();
|
||||
CNonceHandler cNonceHandler = keycloakSession.getProvider(CNonceHandler.class);
|
||||
cNonceHandler.verifyCNonce(proofPayload.getNonce(),
|
||||
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(keycloakContext)),
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
|
||||
OID4VCIssuerWellKnownProvider.getNonceEndpoint(keycloakContext)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,9 @@ public class CredentialIssuer {
|
||||
@JsonProperty("credential_endpoint")
|
||||
private String credentialEndpoint;
|
||||
|
||||
@JsonProperty("nonce_endpoint")
|
||||
private String nonceEndpoint;
|
||||
|
||||
@JsonProperty("authorization_servers")
|
||||
private List<String> authorizationServers;
|
||||
|
||||
@ -68,6 +71,15 @@ public class CredentialIssuer {
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getNonceEndpoint() {
|
||||
return nonceEndpoint;
|
||||
}
|
||||
|
||||
public CredentialIssuer setNonceEndpoint(String nonceEndpoint) {
|
||||
this.nonceEndpoint = nonceEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<String, SupportedCredentialConfiguration> getCredentialsSupported() {
|
||||
return credentialsSupported;
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class JwtCNonce extends JsonWebToken {
|
||||
|
||||
/**
|
||||
* cryptographically strong random string that serves as salt for the otherwise predictable c_nonce values
|
||||
*/
|
||||
@JsonProperty("salt")
|
||||
private String salt;
|
||||
|
||||
public String getSalt() {
|
||||
return salt;
|
||||
}
|
||||
|
||||
public JwtCNonce salt(String salt) {
|
||||
this.salt = salt;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* NonceResponse as defined in
|
||||
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-nonce-response
|
||||
*
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class NonceResponse {
|
||||
|
||||
/**
|
||||
* String containing a nonce to be used when creating a proof of possession of the key proof. This value MUST be
|
||||
* unpredictable.
|
||||
*/
|
||||
@JsonProperty("c_nonce")
|
||||
private String nonce;
|
||||
|
||||
/**
|
||||
* @see #nonce
|
||||
*/
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see #nonce
|
||||
*/
|
||||
public void setNonce(String nonce) {
|
||||
this.nonce = nonce;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
#
|
||||
# Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
# and other contributors as indicated by the @author tags.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandlerFactory
|
||||
@ -34,3 +34,4 @@ org.keycloak.theme.freemarker.FreeMarkerSPI
|
||||
org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderSpi
|
||||
org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidatorSpi
|
||||
org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerSpi
|
||||
org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandlerSpi
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.util.oauth.OAuthClient;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class NonceEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testGetCNonce() throws Exception {
|
||||
URI baseUri = RealmsResource.realmBaseUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build(
|
||||
AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
|
||||
OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
String cNonce = getCNonce();
|
||||
|
||||
URI oid4vcUri;
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
|
||||
OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
String nonceUrl = String.format("%s/%s", oid4vcUri, OID4VCIssuerEndpoint.NONCE_PATH);
|
||||
|
||||
Assert.assertNotNull(cNonce);
|
||||
// verify nonce content
|
||||
{
|
||||
TokenVerifier<JsonWebToken> verifier = TokenVerifier.create(cNonce, JsonWebToken.class);
|
||||
|
||||
JWSHeader jwsHeader = verifier.getHeader();
|
||||
Assert.assertEquals(Algorithm.ES256, jwsHeader.getAlgorithm().name());
|
||||
Assert.assertNotNull(jwsHeader.getKeyId());
|
||||
|
||||
JsonWebToken nonce = verifier.getToken();
|
||||
String credentialsUrl = String.format("%s/%s", oid4vcUri, OID4VCIssuerEndpoint.CREDENTIAL_PATH);
|
||||
Assert.assertEquals(List.of(credentialsUrl), Arrays.asList(nonce.getAudience()));
|
||||
Assert.assertEquals(baseUri.toString(), nonce.getIssuer());
|
||||
Assert.assertEquals(nonceUrl, nonce.getOtherClaims().get(JwtCNonceHandler.SOURCE_ENDPOINT));
|
||||
Assert.assertNotNull(nonce.getOtherClaims().get("salt"));
|
||||
}
|
||||
|
||||
// do internal nonce verification by using cNonceHandler
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
KeycloakContext keycloakContext = session.getContext();
|
||||
cNonceHandler.verifyCNonce(cNonce,
|
||||
List.of(OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(
|
||||
keycloakContext)),
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
|
||||
OID4VCIssuerWellKnownProvider.getNonceEndpoint(keycloakContext)));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -22,6 +22,7 @@ import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.apache.commons.collections4.map.HashedMap;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
@ -29,16 +30,19 @@ import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.oid4vci.Oid4VciConstants;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
@ -48,11 +52,9 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.JwtProof;
|
||||
import org.keycloak.protocol.oid4vc.model.Proof;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofType;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
@ -62,7 +64,6 @@ import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -92,32 +93,121 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testRequestTestCredentialWithKeybinding() {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth);
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateJwtProof(getCredentialIssuer(session), null));
|
||||
testingClient.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, token, proof);
|
||||
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
|
||||
}));
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, token, proof);
|
||||
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
@Test
|
||||
public void testRequestTestCredentialWithInvalidKeybinding() throws Throwable {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() ->
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), null));
|
||||
try {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
})
|
||||
)
|
||||
);
|
||||
testRequestTestCredential(session, token, proof);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
Assert.assertEquals("Could not validate provided proof", ex.getMessage());
|
||||
Assert.assertEquals("Could not verify signature of provided proof", ex.getCause().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProofOfPossessionWithMissingAudience() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
final String nonceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
|
||||
// creates a cNonce with missing data
|
||||
String cNonce = cNonceHandler.buildCNonce(null,
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
|
||||
nonceEndpoint));
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
Assert.assertEquals("""
|
||||
c_nonce: expected 'aud' to be equal to \
|
||||
'[https://localhost:8543/auth/realms/test/protocol/oid4vc/credential]' but \
|
||||
actual value was '[]'""",
|
||||
ExceptionUtils.getRootCause(ex).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProofOfPossessionWithIllegalSourceEndpoint() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
final String credentialsEndpoint = //
|
||||
OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
|
||||
// creates a cNonce with missing data
|
||||
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), null);
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
Assert.assertEquals("""
|
||||
c_nonce: expected 'source_endpoint' to be equal to \
|
||||
'https://localhost:8543/auth/realms/test/protocol/oid4vc/nonce' but \
|
||||
actual value was 'null'""",
|
||||
ExceptionUtils.getRootCause(ex).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProofOfPossessionWithExpiredState() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
|
||||
final String credentialsEndpoint = //
|
||||
OID4VCIssuerWellKnownProvider.getCredentialsEndpoint(session.getContext());
|
||||
final String nonceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
|
||||
try {
|
||||
// make the exp-value negative to set the exp-time in the past
|
||||
session.getContext().getRealm().setAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, -1);
|
||||
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint),
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint));
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
} finally {
|
||||
// make sure other tests are not affected by the changed realm-attribute
|
||||
session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS);
|
||||
}
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
String message = ExceptionUtils.getRootCause(ex).getMessage();
|
||||
Assert.assertTrue(String.format("Message '%s' should match regular expression", message),
|
||||
message.matches("c_nonce not valid: \\d+\\(exp\\) < \\d+\\(now\\)"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String getCredentialIssuer(KeycloakSession session) {
|
||||
@ -229,6 +319,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
public void getConfig() {
|
||||
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
|
||||
String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential";
|
||||
String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH;
|
||||
final String expectedAuthorizationServer = expectedIssuer;
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
@ -239,6 +330,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig;
|
||||
assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
|
||||
assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
|
||||
assertEquals("The correct nonce endpoint should be included.",
|
||||
expectedNonceEndpoint,
|
||||
credentialIssuer.getNonceEndpoint());
|
||||
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
|
||||
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
|
||||
assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
|
||||
|
||||
@ -17,9 +17,17 @@
|
||||
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import jakarta.ws.rs.client.Client;
|
||||
import jakarta.ws.rs.client.Invocation;
|
||||
import jakarta.ws.rs.client.WebTarget;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.junit.Assert;
|
||||
import org.keycloak.admin.client.resource.ClientScopeResource;
|
||||
import org.keycloak.admin.client.resource.ProtocolMappersResource;
|
||||
import org.keycloak.common.Profile;
|
||||
@ -36,11 +44,13 @@ import org.keycloak.jose.jws.JWSBuilder;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.NonceResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
@ -48,10 +58,15 @@ import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.representations.idm.RoleRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.util.AdminClientUtil;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
import org.keycloak.testsuite.util.oauth.OAuthClient;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
@ -470,4 +485,46 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
.jwk(jwk)
|
||||
.jsonContent(token);
|
||||
}
|
||||
|
||||
public static String getCNonce() {
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
URI oid4vcUri = RealmsResource.protocolUrl(builder).build(AbstractTestRealmKeycloakTest.TEST_REALM_NAME,
|
||||
OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
String nonceUrl = String.format("%s/%s", oid4vcUri.toString(), OID4VCIssuerEndpoint.NONCE_PATH);
|
||||
|
||||
String nonceResponseString;
|
||||
// request cNonce
|
||||
try (Client client = AdminClientUtil.createResteasyClient()) {
|
||||
WebTarget nonceTarget = client.target(nonceUrl);
|
||||
// the nonce endpoint must be unprotected, and therefore it must be accessible without any authentication
|
||||
Invocation.Builder nonceInvocationBuilder = nonceTarget.request()
|
||||
// just making sure that no authentication is added
|
||||
// by interceptors or similar
|
||||
.header(HttpHeaders.AUTHORIZATION, null)
|
||||
.header(HttpHeaders.COOKIE, null);
|
||||
|
||||
try (Response response = nonceInvocationBuilder.post(null)) {
|
||||
Assert.assertEquals(HttpStatus.SC_OK, response.getStatus());
|
||||
Assert.assertTrue(response.getMediaType().toString().startsWith(MediaType.APPLICATION_JSON_TYPE.toString()));
|
||||
nonceResponseString = parseResponse(response);
|
||||
Assert.assertNotNull(nonceResponseString);
|
||||
Assert.assertEquals("no-store", response.getHeaderString(HttpHeaders.CACHE_CONTROL));
|
||||
}
|
||||
}
|
||||
NonceResponse nonceResponse;
|
||||
try {
|
||||
nonceResponse = JsonSerialization.readValue(nonceResponseString, NonceResponse.class);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return nonceResponse.getNonce();
|
||||
}
|
||||
|
||||
public static String parseResponse(Response response) {
|
||||
try {
|
||||
return response.readEntity(String.class);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user