[OID4VCI] Add nonce endpoint (#39479)

fixes #39272

Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
This commit is contained in:
Pascal Knüppel 2025-06-05 10:11:46 +02:00 committed by GitHub
parent 192c7bed57
commit 193bee0c6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 860 additions and 43 deletions

View File

@ -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() {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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