Skip to content

Commit

Permalink
Add OIDC TokenCertificateValidator
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Apr 14, 2024
1 parent 2823d85 commit 8afd81c
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check warning on line 473 in docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'JWT token certificate chain'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'JWT token certificate chain'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 473, "column": 5}}}, "severity": "INFO"}

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.

Check warning on line 475 in docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 475, "column": 54}}}, "severity": "INFO"}
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.

Check warning on line 483 in docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'avaiable'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'avaiable'?", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 483, "column": 216}}}, "severity": "WARNING"}

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`:

Check warning on line 487 in docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 487, "column": 105}}}, "severity": "INFO"}

[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<X509Certificate> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<X509Certificate> chain, String tokenClaims) throws CertificateException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> thumbprints;
final Optional<String> expectedLeafCertificateName;
final List<TokenCertificateValidator> 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
Expand All @@ -45,34 +53,52 @@ public Key resolveKey(JsonWebSignature jws, List<JsonWebStructure> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public DynamicVerificationKeyResolver(OidcProviderClient client, OidcTenantConfi
this.cache = new MemoryCache<Key>(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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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");
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -618,24 +605,4 @@ public String validate(JwtContext jwtContext) throws MalformedClaimException {
}
}

private static List<Validator> getCustomValidators(OidcTenantConfig oidcTenantConfig) {
if (oidcTenantConfig != null && oidcTenantConfig.tenantId.isPresent()) {
var tenantsValidators = new ArrayList<Validator>();
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;
}
}
Loading

0 comments on commit 8afd81c

Please sign in to comment.