From 8afd81c9c553801ca9bc8a0271559d4e1273f66f Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Fri, 12 Apr 2024 20:15:21 +0100 Subject: [PATCH] Add OIDC TokenCertificateValidator --- ...rity-oidc-bearer-token-authentication.adoc | 60 ++++++++++++++++ .../oidc/TokenCertificateValidator.java | 25 +++++++ .../runtime/CertChainPublicKeyResolver.java | 68 +++++++++++++------ .../DynamicVerificationKeyResolver.java | 2 +- .../io/quarkus/oidc/runtime/OidcProvider.java | 59 ++++------------ ...erFinder.java => TenantFeatureFinder.java} | 30 +++++++- .../io/quarkus/it/keycloak/AdminResource.java | 8 +++ .../BearerGlobalTokenChainValidator.java | 29 ++++++++ .../BearerTenantTokenChainValidator.java | 34 ++++++++++ .../src/main/resources/application.properties | 3 + .../BearerTokenAuthorizationTest.java | 54 ++++++++++++++- .../io/quarkus/it/keycloak/TestUtils.java | 2 + 12 files changed, 302 insertions(+), 72 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCertificateValidator.java rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/{TokenCustomizerFinder.java => TenantFeatureFinder.java} (53%) create mode 100644 integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerGlobalTokenChainValidator.java create mode 100644 integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerTenantTokenChainValidator.java diff --git a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc index 2a02b3292ca03..4d764c915a9e9 100644 --- a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc @@ -470,6 +470,66 @@ quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect For information about bearer access token propagation to the downstream services, see the xref:security-openid-connect-client-reference.adoc#token-propagation[Token propagation] section of the Quarkus "OpenID Connect (OIDC) and OAuth2 client and filters reference" guide. +=== JWT token certificate chain + +In some cases, JWT bearer tokens have an `x5c` header which represents an X509 certificate chain whose leaf certificate contains a public key that must be used to verify this token's signature. +Before this public key can be accepted to verify the signature, the certificate chain must be validated first. +The certificate chain validation involves several steps: + +1. Confirm that every certificate but the root one is signed by the parent certificate. + +2. Confirm the chain's root certificate is also imported in the truststore. + +3. Validate the chain's leaf certificate. If a common name of the leaf certificate is configured then a common name of the chain's leaf certificate must match it. Otherwise the chain's leaf certificate must also be avaiable in the truststore, unless one or more custom `TokenCertificateValidator` implementations are registered. + +4. `quarkus.oidc.TokenCertificateValidator` can be used to add a custom certificate chain validation step. It can be used by all tenants expecting tokens with the certificate chain or bound to specific OIDC tenants with the `@quarkus.oidc.TenantFeature` annotation. + +For example, here is how you can configure Quarkus OIDC to verify the token's certificate chain, without using `quarkus.oidc.TokenCertificateValidator`: + +[source,properties] +---- +quarkus.oidc.certificate-chain.trust-store-file=truststore-rootcert.p12 <1> +quarkus.oidc.certificate-chain.trust-store-password=storepassword +quarkus.oidc.certificate-chain.leaf-certificate-name=www.quarkusio.com <2> +---- +<1> The truststore must contain the certificate chain's root certificate. +<2> The certificate chain's leaf certificate must have a common name equal to `www.quarkusio.com`. If this property is not configured then the truststore must contain the certificate chain's leaf certificate unless one or more custom `TokenCertificateValidator` implementations are registered. + +You can add a custom certificate chain validation step by registering a custom `quarkus.oidc.TokenCertificateValidator`, for example: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCertificateValidator; +import io.quarkus.oidc.runtime.TrustStoreUtils; +import io.vertx.core.json.JsonObject; + +@ApplicationScoped +@Unremovable +public class BearerGlobalTokenChainValidator implements TokenCertificateValidator { + + @Override + public void validate(OidcTenantConfig oidcConfig, List chain, String tokenClaims) throws CertificateException { + String rootCertificateThumbprint = TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1)); + JsonObject claims = new JsonObject(tokenClaims); + if (!rootCertificateThumbprint.equals(claims.getString("root-certificate-thumbprint"))) { <1> + throw new CertificateException("Invalid root certificate"); + } + } +} + +---- +<1> Confirm that the certificate chain's root certificate is bound to the custom JWT token's claim. + === OIDC provider client authentication `quarkus.oidc.runtime.OidcProviderClient` is used when a remote request to an OIDC provider is required. diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCertificateValidator.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCertificateValidator.java new file mode 100644 index 0000000000000..f6914aedfd2cc --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenCertificateValidator.java @@ -0,0 +1,25 @@ +package io.quarkus.oidc; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +/** + * TokenCertificateValidator can be used to verify X509 certificate chain + * that is inlined in the JWT token as a 'x5c' header value. + * + * Use {@link TenantFeature} qualifier to bind this validator to specific OIDC tenants. + */ +public interface TokenCertificateValidator { + /** + * Validate X509 certificate chain + * + * @param oidcConfig current OIDC tenant configuration. + * @param chain the certificate chain. The first element in the list is a leaf certificate, the last element - the root + * certificate. + * @param tokenClaims the decoded JWT token claims in JSON format. If necessary, implementations can convert it to JSON + * object. + * @throws {@link CertificateException} if the certificate chain validation has failed. + */ + void validate(OidcTenantConfig oidcConfig, List chain, String tokenClaims) throws CertificateException; +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CertChainPublicKeyResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CertChainPublicKeyResolver.java index 133be33ae688c..d8d1999f0d20f 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CertChainPublicKeyResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CertChainPublicKeyResolver.java @@ -11,24 +11,32 @@ import org.jose4j.jwx.JsonWebStructure; import org.jose4j.lang.UnresolvableKeyException; -import io.quarkus.oidc.OidcTenantConfig.CertificateChain; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCertificateValidator; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.runtime.X509IdentityProvider; import io.vertx.ext.auth.impl.CertificateHelper; public class CertChainPublicKeyResolver implements RefreshableVerificationKeyResolver { private static final Logger LOG = Logger.getLogger(OidcProvider.class); + final OidcTenantConfig oidcConfig; final Set thumbprints; final Optional expectedLeafCertificateName; + final List certificateValidators; - public CertChainPublicKeyResolver(CertificateChain chain) { - if (chain.getTrustStorePassword().isEmpty()) { + public CertChainPublicKeyResolver(OidcTenantConfig oidcConfig) { + this.oidcConfig = oidcConfig; + if (oidcConfig.certificateChain.getTrustStorePassword().isEmpty()) { throw new ConfigurationException( "Truststore with configured password which keeps thumbprints of the trusted certificates must be present"); } - this.thumbprints = TrustStoreUtils.getTrustedCertificateThumbprints(chain.trustStoreFile.get(), - chain.getTrustStorePassword().get(), chain.trustStoreCertAlias, chain.getTrustStoreFileType()); - this.expectedLeafCertificateName = chain.leafCertificateName; + this.thumbprints = TrustStoreUtils.getTrustedCertificateThumbprints( + oidcConfig.certificateChain.trustStoreFile.get(), + oidcConfig.certificateChain.getTrustStorePassword().get(), + oidcConfig.certificateChain.trustStoreCertAlias, + oidcConfig.certificateChain.getTrustStoreFileType()); + this.expectedLeafCertificateName = oidcConfig.certificateChain.leafCertificateName; + this.certificateValidators = TenantFeatureFinder.find(oidcConfig, TokenCertificateValidator.class); } @Override @@ -45,34 +53,52 @@ public Key resolveKey(JsonWebSignature jws, List nestingContex LOG.debug("Token 'x5c' certificate chain is empty"); return null; } + + // General certificate chain validation + //TODO: support revocation lists + CertificateHelper.checkValidity(chain, null); + if (chain.size() == 1) { + // CertificateHelper.checkValidity does not currently + // verify the certificate signature if it is a single certificate chain + final X509Certificate root = chain.get(0); + root.verify(root.getPublicKey()); + } + + // Always do the root certificate thumbprint check LOG.debug("Checking a thumbprint of the root chain certificate"); String rootThumbprint = TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1)); if (!thumbprints.contains(rootThumbprint)) { LOG.error("Thumprint of the root chain certificate is invalid"); throw new UnresolvableKeyException("Thumprint of the root chain certificate is invalid"); } - if (expectedLeafCertificateName.isEmpty()) { - LOG.debug("Checking a thumbprint of the leaf chain certificate"); - String thumbprint = TrustStoreUtils.calculateThumprint(chain.get(0)); - if (!thumbprints.contains(thumbprint)) { - LOG.error("Thumprint of the leaf chain certificate is invalid"); - throw new UnresolvableKeyException("Thumprint of the leaf chain certificate is invalid"); + + // Run custom validators if any + if (!certificateValidators.isEmpty()) { + LOG.debug("Running custom TokenCertificateValidators"); + for (TokenCertificateValidator validator : certificateValidators) { + validator.validate(oidcConfig, chain, jws.getUnverifiedPayload()); } - } else { + } + + // Finally, check the leaf certificate if required + if (!expectedLeafCertificateName.isEmpty()) { + // Compare the leaf certificate common name against the configured value String leafCertificateName = X509IdentityProvider.getCommonName(chain.get(0).getSubjectX500Principal()); if (!expectedLeafCertificateName.get().equals(leafCertificateName)) { LOG.errorf("Wrong leaf certificate common name: %s", leafCertificateName); throw new UnresolvableKeyException("Wrong leaf certificate common name"); } + } else if (certificateValidators.isEmpty()) { + // No custom validators are registered and no leaf certificate CN is configured + // Check that the truststore contains a leaf certificate thumbprint + LOG.debug("Checking a thumbprint of the leaf chain certificate"); + String thumbprint = TrustStoreUtils.calculateThumprint(chain.get(0)); + if (!thumbprints.contains(thumbprint)) { + LOG.error("Thumprint of the leaf chain certificate is invalid"); + throw new UnresolvableKeyException("Thumprint of the leaf chain certificate is invalid"); + } } - //TODO: support revocation lists - CertificateHelper.checkValidity(chain, null); - if (chain.size() == 1) { - // CertificateHelper.checkValidity does not currently - // verify the certificate signature if it is a single certificate chain - final X509Certificate root = chain.get(0); - root.verify(root.getPublicKey()); - } + return chain.get(0).getPublicKey(); } catch (UnresolvableKeyException ex) { throw ex; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java index dbb2adeb2af49..a2a2d85a2ab96 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java @@ -39,7 +39,7 @@ public DynamicVerificationKeyResolver(OidcProviderClient client, OidcTenantConfi this.cache = new MemoryCache(client.getVertx(), config.jwks.cleanUpTimerInterval, config.jwks.cacheTimeToLive, config.jwks.cacheSize); if (config.certificateChain.trustStoreFile.isPresent()) { - chainResolverFallback = new CertChainPublicKeyResolver(config.certificateChain); + chainResolverFallback = new CertChainPublicKeyResolver(config); } else { chainResolverFallback = null; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index 54c96cbff24e1..a3a826541673c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -4,14 +4,12 @@ import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; -import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; -import jakarta.enterprise.inject.Default; import jakarta.json.JsonObject; import org.eclipse.microprofile.jwt.Claims; @@ -32,14 +30,11 @@ import org.jose4j.lang.InvalidAlgorithmException; import org.jose4j.lang.UnresolvableKeyException; -import io.quarkus.arc.Arc; import io.quarkus.logging.Log; import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; import io.quarkus.oidc.OidcTenantConfig; -import io.quarkus.oidc.OidcTenantConfig.CertificateChain; -import io.quarkus.oidc.TenantFeature.TenantFeatureLiteral; import io.quarkus.oidc.TokenCustomizer; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.UserInfo; @@ -84,8 +79,8 @@ public class OidcProvider implements Closeable { final AlgorithmConstraints requiredAlgorithmConstraints; public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) { - this(client, oidcConfig, jwks, TokenCustomizerFinder.find(oidcConfig), tokenDecryptionKey, - getCustomValidators(oidcConfig)); + this(client, oidcConfig, jwks, TenantFeatureFinder.find(oidcConfig), tokenDecryptionKey, + TenantFeatureFinder.find(oidcConfig, Validator.class)); } public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, @@ -94,10 +89,9 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json this.oidcConfig = oidcConfig; this.tokenCustomizer = tokenCustomizer; if (jwks != null) { - this.asymmetricKeyResolver = new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval, - oidcConfig.certificateChain); + this.asymmetricKeyResolver = new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval); } else if (oidcConfig != null && oidcConfig.certificateChain.trustStoreFile.isPresent()) { - this.asymmetricKeyResolver = new CertChainPublicKeyResolver(oidcConfig.certificateChain); + this.asymmetricKeyResolver = new CertChainPublicKeyResolver(oidcConfig); } else { this.asymmetricKeyResolver = null; } @@ -112,22 +106,17 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json this.requiredClaims = checkRequiredClaimsProp(); this.tokenDecryptionKey = tokenDecryptionKey; this.requiredAlgorithmConstraints = checkSignatureAlgorithm(); - - if (customValidators != null && !customValidators.isEmpty()) { - this.customValidators = customValidators; - } else { - this.customValidators = null; - } + this.customValidators = customValidators == null ? List.of() : customValidators; } public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenDecryptionKey) { this.client = null; this.oidcConfig = oidcConfig; - this.tokenCustomizer = TokenCustomizerFinder.find(oidcConfig); + this.tokenCustomizer = TenantFeatureFinder.find(oidcConfig); if (publicKeyEnc != null) { this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc); } else if (oidcConfig.certificateChain.trustStoreFile.isPresent()) { - this.asymmetricKeyResolver = new CertChainPublicKeyResolver(oidcConfig.certificateChain); + this.asymmetricKeyResolver = new CertChainPublicKeyResolver(oidcConfig); } else { throw new IllegalStateException("Neither public key nor certificate chain verification modes are enabled"); } @@ -137,7 +126,7 @@ public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenD this.requiredClaims = checkRequiredClaimsProp(); this.tokenDecryptionKey = tokenDecryptionKey; this.requiredAlgorithmConstraints = checkSignatureAlgorithm(); - this.customValidators = getCustomValidators(oidcConfig); + this.customValidators = TenantFeatureFinder.find(oidcConfig, Validator.class); } private AlgorithmConstraints checkSignatureAlgorithm() { @@ -223,10 +212,8 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, builder.registerValidator(new CustomClaimsValidator(Map.of(OidcConstants.NONCE, nonce))); } - if (customValidators != null) { - for (Validator customValidator : customValidators) { - builder.registerValidator(customValidator); - } + for (Validator customValidator : customValidators) { + builder.registerValidator(customValidator); } if (issuedAtRequired) { @@ -438,11 +425,11 @@ private class JsonWebKeyResolver implements RefreshableVerificationKeyResolver { volatile long forcedJwksRefreshIntervalMilliSecs; final CertChainPublicKeyResolver chainResolverFallback; - JsonWebKeyResolver(JsonWebKeySet jwks, Duration forcedJwksRefreshInterval, CertificateChain chain) { + JsonWebKeyResolver(JsonWebKeySet jwks, Duration forcedJwksRefreshInterval) { this.jwks = jwks; this.forcedJwksRefreshIntervalMilliSecs = forcedJwksRefreshInterval.toMillis(); - if (chain.trustStoreFile.isPresent()) { - chainResolverFallback = new CertChainPublicKeyResolver(chain); + if (oidcConfig.certificateChain.trustStoreFile.isPresent()) { + chainResolverFallback = new CertChainPublicKeyResolver(oidcConfig); } else { chainResolverFallback = null; } @@ -618,24 +605,4 @@ public String validate(JwtContext jwtContext) throws MalformedClaimException { } } - private static List getCustomValidators(OidcTenantConfig oidcTenantConfig) { - if (oidcTenantConfig != null && oidcTenantConfig.tenantId.isPresent()) { - var tenantsValidators = new ArrayList(); - for (var instance : Arc.container().listAll(Validator.class, Default.Literal.INSTANCE)) { - if (instance.isAvailable()) { - tenantsValidators.add(instance.get()); - } - } - for (var instance : Arc.container().listAll(Validator.class, - TenantFeatureLiteral.of(oidcTenantConfig.tenantId.get()))) { - if (instance.isAvailable()) { - tenantsValidators.add(instance.get()); - } - } - if (!tenantsValidators.isEmpty()) { - return List.copyOf(tenantsValidators); - } - } - return null; - } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantFeatureFinder.java similarity index 53% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantFeatureFinder.java index d09633054b5fa..11a918f6dd5ba 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantFeatureFinder.java @@ -1,16 +1,22 @@ package io.quarkus.oidc.runtime; +import java.util.ArrayList; +import java.util.List; + +import jakarta.enterprise.inject.Default; + import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; import io.quarkus.arc.InstanceHandle; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TenantFeature; +import io.quarkus.oidc.TenantFeature.TenantFeatureLiteral; import io.quarkus.oidc.TokenCustomizer; -public class TokenCustomizerFinder { +public class TenantFeatureFinder { - private TokenCustomizerFinder() { + private TenantFeatureFinder() { } @@ -37,4 +43,24 @@ public static TokenCustomizer find(OidcTenantConfig oidcConfig) { return null; } + public static List find(OidcTenantConfig oidcTenantConfig, Class tenantFeatureClass) { + if (oidcTenantConfig != null && oidcTenantConfig.tenantId.isPresent()) { + var tenantsValidators = new ArrayList(); + for (var instance : Arc.container().listAll(tenantFeatureClass, Default.Literal.INSTANCE)) { + if (instance.isAvailable()) { + tenantsValidators.add(instance.get()); + } + } + for (var instance : Arc.container().listAll(tenantFeatureClass, + TenantFeatureLiteral.of(oidcTenantConfig.tenantId.get()))) { + if (instance.isAvailable()) { + tenantsValidators.add(instance.get()); + } + } + if (!tenantsValidators.isEmpty()) { + return List.copyOf(tenantsValidators); + } + } + return List.of(); + } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java index 59664d4e21510..866a6d9ea0a40 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java @@ -61,6 +61,14 @@ public String bearerCertificateFullChain() { return "granted:" + identity.getRoles(); } + @Path("bearer-chain-custom-validator") + @GET + @RolesAllowed("admin") + @Produces(MediaType.APPLICATION_JSON) + public String bearerCertificateCustomValidator() { + return "granted:" + identity.getRoles(); + } + @Path("bearer-certificate-full-chain-root-only") @GET @RolesAllowed("admin") diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerGlobalTokenChainValidator.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerGlobalTokenChainValidator.java new file mode 100644 index 0000000000000..d7e3589470420 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerGlobalTokenChainValidator.java @@ -0,0 +1,29 @@ +package io.quarkus.it.keycloak; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenCertificateValidator; +import io.quarkus.oidc.runtime.TrustStoreUtils; +import io.vertx.core.json.JsonObject; + +@ApplicationScoped +@Unremovable +public class BearerGlobalTokenChainValidator implements TokenCertificateValidator { + + @Override + public void validate(OidcTenantConfig oidcConfig, List chain, String tokenClaims) + throws CertificateException { + String rootCertificateThumbprint = TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1)); + JsonObject claims = new JsonObject(tokenClaims); + if (!rootCertificateThumbprint.equals(claims.getString("root-certificate-thumbprint"))) { + throw new CertificateException("Invalid root certificate"); + } + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerTenantTokenChainValidator.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerTenantTokenChainValidator.java new file mode 100644 index 0000000000000..39a1ce4c06837 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/BearerTenantTokenChainValidator.java @@ -0,0 +1,34 @@ +package io.quarkus.it.keycloak; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TenantFeature; +import io.quarkus.oidc.TokenCertificateValidator; +import io.quarkus.oidc.runtime.TrustStoreUtils; +import io.vertx.core.json.JsonObject; + +@ApplicationScoped +@Unremovable +@TenantFeature("bearer-chain-custom-validator") +public class BearerTenantTokenChainValidator implements TokenCertificateValidator { + + @Override + public void validate(OidcTenantConfig oidcConfig, List chain, String tokenClaims) + throws CertificateException { + if (!"bearer-chain-custom-validator".equals(oidcConfig.tenantId.get())) { + throw new RuntimeException("Unexpected tenant id"); + } + String leafCertificateThumbprint = TrustStoreUtils.calculateThumprint(chain.get(0)); + JsonObject claims = new JsonObject(tokenClaims); + if (!leafCertificateThumbprint.equals(claims.getString("leaf-certificate-thumbprint"))) { + throw new CertificateException("Invalid leaf certificate"); + } + } + +} diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 807d619906036..15e351b94c6bf 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -196,6 +196,9 @@ quarkus.oidc.bearer-no-introspection.token.allow-jwt-introspection=false quarkus.oidc.bearer-certificate-full-chain.certificate-chain.trust-store-file=truststore.p12 quarkus.oidc.bearer-certificate-full-chain.certificate-chain.trust-store-password=storepassword +quarkus.oidc.bearer-chain-custom-validator.certificate-chain.trust-store-file=truststore.p12 +quarkus.oidc.bearer-chain-custom-validator.certificate-chain.trust-store-password=storepassword + quarkus.oidc.bearer-certificate-full-chain-root-only-wrongcname.certificate-chain.trust-store-file=truststore-rootcert.p12 quarkus.oidc.bearer-certificate-full-chain-root-only-wrongcname.certificate-chain.trust-store-password=storepassword quarkus.oidc.bearer-certificate-full-chain-root-only-wrongcname.certificate-chain.leaf-certificate-name=www.quarkusio.com diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index d95361d301e6c..af9862304184f 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -29,6 +29,7 @@ import io.quarkus.deployment.util.FileUtil; import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.oidc.runtime.TrustStoreUtils; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.oidc.server.OidcWireMock; @@ -36,6 +37,7 @@ import io.restassured.RestAssured; import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.build.Jwt; +import io.smallrye.jwt.build.JwtClaimsBuilder; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.jwt.util.ResourceUtils; import io.vertx.core.json.JsonObject; @@ -187,13 +189,44 @@ public void testAccessAdminResourceWithWrongCertS256Thumbprint() { .statusCode(401); } + @Test + public void testCertChainWithCustomValidator() throws Exception { + X509Certificate rootCert = KeyUtils.getCertificate(ResourceUtils.readResource("/ca.cert.pem")); + X509Certificate intermediateCert = KeyUtils.getCertificate(ResourceUtils.readResource("/intermediate.cert.pem")); + X509Certificate subjectCert = KeyUtils.getCertificate(ResourceUtils.readResource("/www.quarkustest.com.cert.pem")); + PrivateKey subjectPrivateKey = KeyUtils.readPrivateKey("/www.quarkustest.com.key.pem"); + + // Send the token with the valid certificate chain and bind it to the token claim + String accessToken = getAccessTokenForCustomValidator( + List.of(subjectCert, intermediateCert, rootCert), + subjectPrivateKey, true); + + RestAssured.given().auth().oauth2(accessToken) + .when().get("/api/admin/bearer-chain-custom-validator") + .then() + .statusCode(200) + .body(Matchers.containsString("admin")); + + // Send the token with the valid certificate chain but do bind it to the token claim + accessToken = getAccessTokenForCustomValidator( + List.of(subjectCert, intermediateCert, rootCert), + subjectPrivateKey, false); + + RestAssured.given().auth().oauth2(accessToken) + .when().get("/api/admin/bearer-chain-custom-validator") + .then() + .statusCode(401); + + } + @Test public void testAccessAdminResourceWithFullCertChain() throws Exception { X509Certificate rootCert = KeyUtils.getCertificate(ResourceUtils.readResource("/ca.cert.pem")); X509Certificate intermediateCert = KeyUtils.getCertificate(ResourceUtils.readResource("/intermediate.cert.pem")); X509Certificate subjectCert = KeyUtils.getCertificate(ResourceUtils.readResource("/www.quarkustest.com.cert.pem")); PrivateKey subjectPrivateKey = KeyUtils.readPrivateKey("/www.quarkustest.com.key.pem"); - // Send the token with the valid certificate chain + + // Send the token with the valid certificate chain and bind it to the token claim String accessToken = getAccessTokenWithCertChain( List.of(subjectCert, intermediateCert, rootCert), subjectPrivateKey); @@ -708,7 +741,24 @@ private String getAccessTokenWithCertChain(List chain, .groups("admin") .issuer("https://server.example.com") .audience("https://service.example.com") - .jws().chain(chain) + .claim("root-certificate-thumbprint", TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1))) + .jws() + .chain(chain) + .sign(privateKey); + } + + private String getAccessTokenForCustomValidator(List chain, + PrivateKey privateKey, boolean setLeafCertThumbprint) throws Exception { + JwtClaimsBuilder builder = Jwt.preferredUserName("alice") + .groups("admin") + .issuer("https://server.example.com") + .audience("https://service.example.com") + .claim("root-certificate-thumbprint", TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1))); + if (setLeafCertThumbprint) { + builder.claim("leaf-certificate-thumbprint", TrustStoreUtils.calculateThumprint(chain.get(0))); + } + return builder.jws() + .chain(chain) .sign(privateKey); } diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java index a7439ceacd048..591ca8c360f4d 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java @@ -8,6 +8,7 @@ import java.util.List; import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.oidc.runtime.TrustStoreUtils; import io.smallrye.jwt.build.Jwt; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.jwt.util.ResourceUtils; @@ -36,6 +37,7 @@ public static String getAccessTokenWithCertChain(List chain, .groups("admin") .issuer("https://server.example.com") .audience("https://service.example.com") + .claim("root-certificate-thumbprint", TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1))) .jws().chain(chain) .sign(privateKey); }