From 4bd6795f3d76f61a58d173ef6b3d290cbae8d8ba Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Fri, 25 Oct 2024 15:49:29 +0300 Subject: [PATCH 01/11] Add XFCC request authorization support Signed-off-by: Tero Saarni --- README.md | 31 ++++ pom.xml | 6 +- .../EnvoyProxySslClientCertificateLookup.java | 97 +++++++++++ ...roxySslClientCertificateLookupFactory.java | 43 ++++- .../x509/ClientCertificateLookupTest.java | 7 +- .../services/x509/HttpRequestImpl.java | 7 +- .../services/x509/PathValidationTest.java | 153 ++++++++++++++++++ .../keycloak/services/x509/ScopeImpl.java | 99 ++++++++++++ src/test/resources/logging.properties | 3 +- 9 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java create mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java diff --git a/README.md b/README.md index cc2a68e..ade093b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,37 @@ This project may require updates for newer Keycloak versions. Refer to Keycloak's [Configuring Providers](https://www.keycloak.org/server/configuration-provider) documentation for more information. +## Configuration + +### Authorizing clients that are allowed to send XFCC headers + +If Keycloak is deployed in environment where not all requests are forwarded via the proxy, it is important to ensure that only requests from the proxy are allowed to send XFCC headers. +This is to prevent clients running inside the perimeter of the proxy from impersonating users. +The prerequisite for this is that the proxy uses TLS and client certificate authentication for the connection to Keycloak. +When the TLS connection is established, Keycloak will verify the client certificate, including the the certificate chain against trusted CAs. +After successful verification, the request is sent to Envoy Client certificate lookup SPI, which then uses the certificate chain information to authorize the use of XFCC headers. + +The authorization is configured by specifying the expected list of X509 subject names in the client certificate chain: + +``` +--spi-x509cert-lookup-envoy-cert-path-verify="[ [ , , ... ], ... ]" +``` + +The configuration is a JSON array of arrays. +Multiple chains of subject names can be specified in the configuration. +Each inner array represents a certificate chain, where the first element is the subject name of the leaf certificate and the following elements are for the intermediate certificates. +Root certificate is not included in the configuration. + +For example, to allow the client certificate chain with the subject name `CN=envoy, O=example.com` and the intermediate certificate with the subject name `CN=intermediate, O=example.com`, use the following configuration: + +``` +--spi-x509cert-lookup-envoy-cert-path-verify='[["CN=envoy, O=example.com", "CN=intermediate, O=example.com"]]' +``` + +If the parameter is not set, the client certificate chain is not verified and all requests with XFCC headers are allowed. + + + ## Development This section is for developers who wish to contribute to the project. diff --git a/pom.xml b/pom.xml index d9e0838..7d7c395 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,8 @@ org.jboss.logmanager.LogManager + + ${project.basedir}/src/test/resources/logging.properties @@ -141,7 +143,7 @@ - + maven-failsafe-plugin ${maven.failsafe.plugin.version} @@ -165,7 +167,7 @@ - + org.apache.maven.plugins maven-checkstyle-plugin diff --git a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java index d50dbed..cbddec3 100644 --- a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java +++ b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java @@ -12,8 +12,13 @@ import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; import java.util.StringTokenizer; +import javax.security.auth.x500.X500Principal; + +import org.jboss.logging.Logger; import org.keycloak.http.HttpRequest; import org.keycloak.services.x509.X509ClientCertificateLookup; @@ -22,10 +27,31 @@ */ public class EnvoyProxySslClientCertificateLookup implements X509ClientCertificateLookup { + private static Logger logger = Logger.getLogger(EnvoyProxySslClientCertificateLookup.class); + protected final static String XFCC_HEADER = "x-forwarded-client-cert"; protected final static String XFCC_HEADER_CERT_KEY = "Cert"; protected final static String XFCC_HEADER_CHAIN_KEY = "Chain"; + // Each element in the list is a list of subject names expected in the client certificate chain. + // + private List> validCertPaths = null; + + /** + * Constructor for creating an instance of EnvoyProxySslClientCertificateLookup. + */ + public EnvoyProxySslClientCertificateLookup() { + } + + /** + * Constructor for creating an instance of EnvoyProxySslClientCertificateLookup. + * + * @param validCertPaths The certificate paths to validate the client certificate chain. + */ + EnvoyProxySslClientCertificateLookup(List> validCertPaths) { + this.validCertPaths = validCertPaths; + } + @Override public void close() { } @@ -55,11 +81,22 @@ public void close() { */ @Override public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws GeneralSecurityException { + // Before processing the XFCC header: + // 1. Check if TLS level authorization is configured. + // 2. Check if the TLS level client certificate chain matches the configured valid certificate paths. + if (validCertPaths != null && !validCertPaths.isEmpty() && !xfccAuthorized(httpRequest)) { + // Request is not coming from authorized client, fall back to the client certificate chain in the TLS layer. + logger.debug("The client certificate chain does not match the configured valid certificate paths. Falling back to the TLS layer client certificate chain."); + return httpRequest.getClientCertificateChain(); + } + String xfcc = httpRequest.getHttpHeaders().getRequestHeaders().getFirst(XFCC_HEADER); if (xfcc == null) { return null; } + logger.debugv("Received x-forwarded-client-cert: {0}", xfcc); + // When multiple nested proxies are involved, the XFCC header may have multiple elements. // Extract only the first (leftmost) XFCC element, which is added by the outermost proxy that terminates the client's TLS connection. int comma = xfcc.indexOf(","); @@ -88,9 +125,69 @@ public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws Gen } } + logger.debugv("Returning certificate chain with {0} certificates", certs != null ? certs.length : 0); + if (certs != null && logger.isDebugEnabled()) { + for (X509Certificate cert : certs) { + logger.debugv("Subject: {0}, Issuer: {1}", cert.getSubjectX500Principal(), cert.getIssuerX500Principal()); + } + } + return certs; } + private boolean xfccAuthorized(HttpRequest httpRequest) { + X509Certificate[] clientChain = httpRequest.getClientCertificateChain(); + if (clientChain == null || clientChain.length == 0) { + logger.debug("No client certificate chain found in the TLS layer."); + return false; + } + + return isClientCertPathValid(clientChain); + } + + /** + * Validates the client certificate chain against the configured valid certificate paths. + */ + private boolean isClientCertPathValid(X509Certificate[] clientCerts) { + if (validCertPaths.isEmpty()) { + logger.debug("Skipping client certificate chain validation as no certificate paths are configured."); + return true; + } + + // Create a list of subject names from the client certificate chain. + List path = new ArrayList<>(); + for (X509Certificate cert : clientCerts) { + path.add(cert.getSubjectX500Principal()); + } + + logger.debugv("Client certificate chain path: {0}", path); + + for (List validPath : validCertPaths) { + logger.debugv("Expected certificate path: {0}", validPath); + + // Valid path cannot be longer than the client certificate chain. + if (path.size() < validPath.size()) { + continue; + } + + boolean match = true; + for (int i = 0; i < validPath.size(); i++) { + if (!path.get(i).equals(validPath.get(i))) { + match = false; + break; + } + } + if (match) { + logger.debug("Client certificate chain matches the expected certificate path."); + return true; + } + + } + + logger.debug("Client certificate chain does not match any of the expected certificate paths."); + return false; + } + /** * Decodes the URL encoded value and removes enclosing quotes if present. */ diff --git a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java index b4499a4..4badf2c 100644 --- a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java +++ b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java @@ -8,12 +8,26 @@ */ package io.github.nordix.keycloak.services.x509; +import java.io.IOException; +import java.util.List; + +import javax.security.auth.x500.X500Principal; + import org.keycloak.Config.Scope; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.services.x509.X509ClientCertificateLookup; import org.keycloak.services.x509.X509ClientCertificateLookupFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; + + /** * Factory for creating EnvoyProxySslClientCertificateLookup instances. */ @@ -21,13 +35,29 @@ public class EnvoyProxySslClientCertificateLookupFactory implements X509ClientCe private final static String PROVIDER = "envoy"; + private List> validCertPaths; + @Override - public X509ClientCertificateLookup create(KeycloakSession session) { - return new EnvoyProxySslClientCertificateLookup(); + public void init(Scope config) { + String pathsJson = config.get("cert-paths"); + if (pathsJson != null) { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(X500Principal.class, new X500PrincipalDeserializer()); + mapper.registerModule(module); + + try { + validCertPaths = mapper.readValue(pathsJson, new TypeReference>>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to parse cert-paths", e); + } + + } } @Override - public void init(Scope config) { + public X509ClientCertificateLookup create(KeycloakSession session) { + return new EnvoyProxySslClientCertificateLookup(validCertPaths); } @Override @@ -42,4 +72,11 @@ public void close() { public String getId() { return PROVIDER; } + + public class X500PrincipalDeserializer extends JsonDeserializer { + @Override + public X500Principal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { + return new X500Principal(p.getValueAsString()); + } + } } diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java index 3e189ea..cbf50f9 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java @@ -17,20 +17,21 @@ import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.crypto.CryptoProvider; import org.keycloak.http.HttpRequest; +import org.keycloak.services.x509.X509ClientCertificateLookup; /** * Unit tests for EnvoyProxySslClientCertificateLookup. */ public class ClientCertificateLookupTest { - private static EnvoyProxySslClientCertificateLookup envoyLookup = null; + private static X509ClientCertificateLookup envoyLookup = null; @BeforeAll public static void setup() { // Initialize the Keycloak default crypto provider. CryptoIntegration.init(CryptoProvider.class.getClassLoader()); - - envoyLookup = new EnvoyProxySslClientCertificateLookup(); + EnvoyProxySslClientCertificateLookupFactory factory = new EnvoyProxySslClientCertificateLookupFactory(); + envoyLookup = factory.create(null); } @Test diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/HttpRequestImpl.java b/src/test/java/io/github/nordix/keycloak/services/x509/HttpRequestImpl.java index eab7b05..8d4f560 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/HttpRequestImpl.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/HttpRequestImpl.java @@ -23,6 +23,7 @@ public class HttpRequestImpl implements HttpRequest { private org.jboss.resteasy.spi.HttpRequest delegate; + private X509Certificate[] clientCertificateChain; public HttpRequestImpl(org.jboss.resteasy.spi.HttpRequest delegate) { this.delegate = delegate; @@ -50,7 +51,7 @@ public MultivaluedMap getMultiPartFormParameters() { @Override public X509Certificate[] getClientCertificateChain() { - throw new UnsupportedOperationException("Unimplemented method 'getClientCertificateChain'"); + return clientCertificateChain; } @Override @@ -58,4 +59,8 @@ public UriInfo getUri() { throw new UnsupportedOperationException("Unimplemented method 'getUri'"); } + public HttpRequestImpl setClientCertificateChain(X509Certificate[] clientCertificateChain) { + this.clientCertificateChain = clientCertificateChain; + return this; + } } diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java new file mode 100644 index 0000000..16005a4 --- /dev/null +++ b/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2024 OpenInfra Foundation Europe and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution, and is available at + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.nordix.keycloak.services.x509; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import org.jboss.resteasy.mock.MockHttpRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.Config.Scope; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; +import org.keycloak.http.HttpRequest; +import org.keycloak.services.x509.X509ClientCertificateLookup; + +import fi.protonode.certy.Credential; + +public class PathValidationTest { + + private static Credential envoy1; + private static Credential envoy2; + private static Credential client1; + private static Credential client2; + + @BeforeAll + public static void createCertificates() throws Exception { + // Initialize the Keycloak default crypto provider. + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + + // Certs used for Envoy->Keycloak the perimeter of the Envoy proxy, e.g. Kubernetes cluster-internal PKI. + Credential internalRootCa = new Credential().subject("CN=internal root CA"); + Credential internalSubCa = new Credential().subject("CN=internal sub CA").ca(true).issuer(internalRootCa); + envoy1 = new Credential().subject("CN=Envoy 1,OU=clients,O=example.com").ca(false).issuer(internalSubCa); + envoy2 = new Credential().subject("CN=Envoy 2,OU=clients,O=example.com").ca(false).issuer(internalSubCa); + + // Following certificfates represent the PKI for external clients. + Credential externalRootCa = new Credential().subject("CN=external root CA"); + Credential externalSubCa = new Credential().subject("CN=external sub CA").ca(true).issuer(externalRootCa); + client1 = new Credential().subject("CN=Client 1,OU=clients,O=example.com").ca(false) + .issuer(externalSubCa); + client2 = new Credential().subject("CN=Client 2,OU=clients,O=example.com").ca(false) + .issuer(externalSubCa); + } + + @Test + public void testTlsRequestWithXfccFromAuthorizedProxy() throws Exception { + X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); + X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + getXfccValue(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + X509Certificate[] certs = lookup.getCertificateChain(request); + + // Check that client1 certificate from XFCC is returned. + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(client1.getCertificates(), certs); + } + + @Test + public void testTlsRequestWithXfccFromUnauthorizedProxy() throws Exception { + X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"CN=does not match\"]]"); + X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + getXfccValue(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that envoy1 certificate is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(envoy1.getCertificates(), certs); + } + + @Test + public void testNonTlsRequestWithXfcc() throws Exception { + X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); + + HttpRequest request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + getXfccValue(client1))); + + // Check that no certificate is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNull(certs); + } + + @Test + public void testTlsRequestWithXfccMultipleAllowedProxies() throws Exception { + X509ClientCertificateLookup lookup = createLookupWithConfig( + "[[\"O=example.com,OU=clients,CN=Envoy 1\"],[\"O=example.com,OU=clients,CN=Envoy 2\"]]"); + + X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + getXfccValue(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that client1 certificate from XFCC is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(client1.getCertificates(), certs); + + tlsLayerClientCerts = getCertificateChain(envoy2); + + request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + getXfccValue(client2))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that client2 certificate from XFCC is returned. + certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(client2.getCertificates(), certs); + } + + private static X509ClientCertificateLookup createLookupWithConfig(String configJson) { + Scope config = ScopeImpl.fromPairs("cert-paths", configJson); + EnvoyProxySslClientCertificateLookupFactory factory = new EnvoyProxySslClientCertificateLookupFactory(); + factory.init(config); + return factory.create(null); + } + + private static X509Certificate[] getCertificateChain(Credential cred) + throws CertificateException, NoSuchAlgorithmException { + return Arrays.stream(cred.getCertificates()).map(cert -> (X509Certificate) cert).toArray(X509Certificate[]::new); + } + + private static String getXfccValue(Credential cred) throws CertificateException, NoSuchAlgorithmException, IOException { + return String.format("Hash=1234;Cert=\"%s\"", URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java b/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java new file mode 100644 index 0000000..e256225 --- /dev/null +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2024 OpenInfra Foundation Europe and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution, and is available at + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.nordix.keycloak.services.x509; + +import java.util.Set; +import java.util.HashMap; +import java.util.Map; + +import org.keycloak.Config.Scope; + +/** + * Minimal implementation of Keycloak's Scope for unit testing purposes. + */ +public class ScopeImpl implements Scope { + + private final Map properties = new HashMap<>(); + + /** + * Create a new Scope instance from a list of key-value pairs. + * + * @param pairs A list of key-value pairs. + * @return A new Scope instance. + */ + public static Scope fromPairs(String... pairs) { + ScopeImpl scope = new ScopeImpl(); + for (int i = 0; i < pairs.length; i += 2) { + scope.put(pairs[i], pairs[i + 1]); + } + return scope; + } + + ScopeImpl() { + } + + void put(String key, String value) { + properties.put(key, value); + } + + @Override + public String get(String key) { + return properties.get(key); + } + + @Override + public String get(String key, String defaultValue) { + return properties.getOrDefault(key, defaultValue); + } + + @Override + public String[] getArray(String key) { + throw new UnsupportedOperationException("Unimplemented method 'getArray'"); + } + + @Override + public Integer getInt(String key) { + throw new UnsupportedOperationException("Unimplemented method 'getInt'"); + } + + @Override + public Integer getInt(String key, Integer defaultValue) { + throw new UnsupportedOperationException("Unimplemented method 'getInt'"); + } + + @Override + public Long getLong(String key) { + throw new UnsupportedOperationException("Unimplemented method 'getLong'"); + } + + @Override + public Long getLong(String key, Long defaultValue) { + throw new UnsupportedOperationException("Unimplemented method 'getLong'"); + } + + @Override + public Boolean getBoolean(String key) { + throw new UnsupportedOperationException("Unimplemented method 'getBoolean'"); + } + + @Override + public Boolean getBoolean(String key, Boolean defaultValue) { + throw new UnsupportedOperationException("Unimplemented method 'getBoolean'"); + } + + @Override + public Scope scope(String... scope) { + throw new UnsupportedOperationException("Unimplemented method 'scope'"); + } + + @Override + public Set getPropertyNames() { + throw new UnsupportedOperationException("Unimplemented method 'getPropertyNames'"); + } +} diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties index 3b141c2..7774870 100644 --- a/src/test/resources/logging.properties +++ b/src/test/resources/logging.properties @@ -1,10 +1,11 @@ logger.level=INFO +#logger.level=DEBUG logger.handlers=CONSOLE handler.CONSOLE=org.jboss.logmanager.handlers.ConsoleHandler handler.CONSOLE.properties=autoFlush -handler.CONSOLE.level=INFO +handler.CONSOLE.level=DEBUG handler.CONSOLE.autoFlush=true handler.CONSOLE.formatter=PATTERN From 89bdddfa8696b9d6ed4222f6f19763608695d688 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Fri, 25 Oct 2024 23:20:38 +0300 Subject: [PATCH 02/11] Better logging Signed-off-by: Tero Saarni --- docker-compose.yaml | 12 +++- pom.xml | 7 ++ .../services/x509/DockerComposeExtension.java | 65 ++++++++++++++----- src/test/resources/logging.properties | 7 +- 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d5e4914..909b5bc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,13 @@ services: - -cxe - | /opt/keycloak/bin/kc.sh import --file /input/src/test/resources/integration-test/keycloak-realm.json - /opt/keycloak/bin/kc.sh start --spi-x509cert-lookup-provider=envoy + /opt/keycloak/bin/kc.sh start \ + --log-level=INFO,io.github.nordix.keycloak.services.x509:debug \ + --spi-x509cert-lookup-provider=envoy \ + --https-certificate-file=/input/target/certs/keycloak.pem \ + --https-certificate-key-file=/input/target/certs/keycloak-key.pem \ + --truststore-paths=/input/target/certs/client-ca.pem + # --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy-client" ]]' \ environment: - KEYCLOAK_ADMIN=admin @@ -30,3 +36,7 @@ services: volumes: - ./:/input:ro - ./target/keycloak-client-cert-lookup-for-envoy-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak-client-cert-lookup-for-envoy.jar:ro + + # Expose Keycloak's HTTPS port to allow test suite to do direct requests. + ports: + - "10443:8443" diff --git a/pom.xml b/pom.xml index 7d7c395..d0af1f0 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 5.11.3 6.2.10.Final 0.4.0 + 1.4.0 3.4.0 3.3.1 3.13.0 @@ -86,6 +87,12 @@ ${certy.version} test + + org.apache.commons + commons-exec + ${apache.commons.exec.version} + test + diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java b/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java index 0d72d5f..b99a2f0 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java @@ -8,20 +8,23 @@ */ package io.github.nordix.keycloak.services.x509; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.ExtensionContext; - -import java.nio.file.Path; +import java.io.File; +import org.apache.commons.exec.CommandLine; +import org.apache.commons.exec.DefaultExecutor; +import org.apache.commons.exec.ExecuteException; +import org.apache.commons.exec.ExecuteResultHandler; import org.jboss.logging.Logger; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; /** * JUnit extension for starting and stopping Docker Compose. */ public class DockerComposeExtension implements BeforeAllCallback, AfterAllCallback { - private static final String DOCKER_COMPOSE_UP = "docker compose up --force-recreate --detach"; + private static final String DOCKER_COMPOSE_UP = "docker compose up --force-recreate --no-color --abort-on-container-exit"; private static final String DOCKER_COMPOSE_DOWN = "docker compose down"; private static Logger logger = Logger.getLogger(DockerComposeExtension.class); @@ -32,27 +35,55 @@ public class DockerComposeExtension implements BeforeAllCallback, AfterAllCallba this.basePath = basePath; } + /** + * Start Docker Compose before all tests but do not wait for it to complete. + * That allows the logs to be displayed in the console in parallel with the test execution. + */ @Override public void beforeAll(ExtensionContext context) throws Exception { - run(DOCKER_COMPOSE_UP, "Failed to start Docker Compose."); - logger.info("To see logs, run 'docker compose logs -f'"); + run(DOCKER_COMPOSE_UP, false, "Failed to start Docker Compose."); } + /** + * Stop Docker Compose after all tests. + */ @Override public void afterAll(ExtensionContext context) throws Exception { - run(DOCKER_COMPOSE_DOWN, "Failed to stop Docker Compose."); + run(DOCKER_COMPOSE_DOWN, true, "Failed to stop Docker Compose."); } - private void run(String command, String errorMessage) throws Exception { - ProcessBuilder processBuilder = new ProcessBuilder(command.split(" ")); - processBuilder.inheritIO(); - processBuilder.directory(Path.of(basePath).toFile()); + /** + * Run a command on a subprocess. + * + * Note: + * Use apache-commons-exec since ProcessBuilder had problems with output redirection + * (output stopped in the middle even if the process was still running). + */ + private void run(String command, boolean waitForCompletion, String errorMessage) throws Exception { + CommandLine cmdLine = CommandLine.parse(command); + DefaultExecutor executor = DefaultExecutor.builder().setWorkingDirectory(new File(basePath)).get(); + + logger.infov("Running command \"{0}\" in directory \"{1}\"", command, executor.getWorkingDirectory()); + + if (waitForCompletion) { + int exitValue = executor.execute(cmdLine); + if (exitValue != 0) { + throw new Exception(errorMessage); + } + } else { + executor.execute(cmdLine, new ExecuteResultHandler() { + @Override + public void onProcessComplete(int exitValue) { + if (exitValue != 0) { + logger.error(errorMessage); + } + } - logger.infov("Running command \"{0}\" in directory \"{1}\"", command, processBuilder.directory()); - Process process = processBuilder.start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new IllegalStateException(errorMessage + " Exit code: " + exitCode); + @Override + public void onProcessFailed(ExecuteException e) { + logger.error(errorMessage, e); + } + }); } } } diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties index 7774870..ec99e36 100644 --- a/src/test/resources/logging.properties +++ b/src/test/resources/logging.properties @@ -1,5 +1,10 @@ + +loggers=org.jboss.logging,io.github.nordix.keycloak.services.x509 + logger.level=INFO -#logger.level=DEBUG + +# Class specific logging levels. +logger.io.github.nordix.keycloak.services.x509.level=DEBUG logger.handlers=CONSOLE From 20ef0a923757aeece93e1d2e0fa572a48b53d6f7 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Fri, 25 Oct 2024 23:22:09 +0300 Subject: [PATCH 03/11] Corrected cert-path-verify configuration variable name Signed-off-by: Tero Saarni --- ...roxySslClientCertificateLookupFactory.java | 6 +++- .../services/x509/PathValidationTest.java | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java index 4badf2c..63465cb 100644 --- a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java +++ b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java @@ -13,6 +13,7 @@ import javax.security.auth.x500.X500Principal; +import org.jboss.logging.Logger; import org.keycloak.Config.Scope; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -33,14 +34,17 @@ */ public class EnvoyProxySslClientCertificateLookupFactory implements X509ClientCertificateLookupFactory { + private static Logger logger = Logger.getLogger(EnvoyProxySslClientCertificateLookupFactory.class); + private final static String PROVIDER = "envoy"; private List> validCertPaths; @Override public void init(Scope config) { - String pathsJson = config.get("cert-paths"); + String pathsJson = config.get("cert-path-verify"); if (pathsJson != null) { + logger.debugv("Client certificate path validation configured: {0}", pathsJson); ObjectMapper mapper = new ObjectMapper(); SimpleModule module = new SimpleModule(); module.addDeserializer(X500Principal.class, new X500PrincipalDeserializer()); diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java index 16005a4..55b960a 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java @@ -40,7 +40,8 @@ public static void createCertificates() throws Exception { // Initialize the Keycloak default crypto provider. CryptoIntegration.init(CryptoProvider.class.getClassLoader()); - // Certs used for Envoy->Keycloak the perimeter of the Envoy proxy, e.g. Kubernetes cluster-internal PKI. + // Certs used for Envoy->Keycloak the perimeter of the Envoy proxy, e.g. + // Kubernetes cluster-internal PKI. Credential internalRootCa = new Credential().subject("CN=internal root CA"); Credential internalSubCa = new Credential().subject("CN=internal sub CA").ca(true).issuer(internalRootCa); envoy1 = new Credential().subject("CN=Envoy 1,OU=clients,O=example.com").ca(false).issuer(internalSubCa); @@ -55,6 +56,10 @@ public static void createCertificates() throws Exception { .issuer(externalSubCa); } + /** + * Test that the client certificate chain is extracted from XFCC header when + * request is over TLS and client certificate matches with the expected path. + */ @Test public void testTlsRequestWithXfccFromAuthorizedProxy() throws Exception { X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); @@ -73,6 +78,11 @@ public void testTlsRequestWithXfccFromAuthorizedProxy() throws Exception { Assertions.assertArrayEquals(client1.getCertificates(), certs); } + /** + * Test that the client certificate chain is not extracted from XFCC header when + * request is over TLS and client certificate does not match with the expected + * path. + */ @Test public void testTlsRequestWithXfccFromUnauthorizedProxy() throws Exception { X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"CN=does not match\"]]"); @@ -90,6 +100,11 @@ public void testTlsRequestWithXfccFromUnauthorizedProxy() throws Exception { Assertions.assertArrayEquals(envoy1.getCertificates(), certs); } + /** + * Test that the client certificate chain is not extracted from XFCC header when + * the request is not over TLS and the configuration requires certificate path + * validation. + */ @Test public void testNonTlsRequestWithXfcc() throws Exception { X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); @@ -103,6 +118,11 @@ public void testNonTlsRequestWithXfcc() throws Exception { Assertions.assertNull(certs); } + /** + * Test that the client certificate chain is extracted from XFCC header when + * request is over TLS and client certificate matches with one of multiple + * expected paths. + */ @Test public void testTlsRequestWithXfccMultipleAllowedProxies() throws Exception { X509ClientCertificateLookup lookup = createLookupWithConfig( @@ -135,8 +155,11 @@ public void testTlsRequestWithXfccMultipleAllowedProxies() throws Exception { Assertions.assertArrayEquals(client2.getCertificates(), certs); } + + // Helper methods. + private static X509ClientCertificateLookup createLookupWithConfig(String configJson) { - Scope config = ScopeImpl.fromPairs("cert-paths", configJson); + Scope config = ScopeImpl.fromPairs("cert-path-verify", configJson); EnvoyProxySslClientCertificateLookupFactory factory = new EnvoyProxySslClientCertificateLookupFactory(); factory.init(config); return factory.create(null); @@ -144,10 +167,13 @@ private static X509ClientCertificateLookup createLookupWithConfig(String configJ private static X509Certificate[] getCertificateChain(Credential cred) throws CertificateException, NoSuchAlgorithmException { - return Arrays.stream(cred.getCertificates()).map(cert -> (X509Certificate) cert).toArray(X509Certificate[]::new); + return Arrays.stream(cred.getCertificates()).map(cert -> (X509Certificate) cert) + .toArray(X509Certificate[]::new); } - private static String getXfccValue(Credential cred) throws CertificateException, NoSuchAlgorithmException, IOException { - return String.format("Hash=1234;Cert=\"%s\"", URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); + private static String getXfccValue(Credential cred) + throws CertificateException, NoSuchAlgorithmException, IOException { + return String.format("Hash=1234;Cert=\"%s\"", + URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); } } From 7c03e985f5d97a0804fa95874021f4331b9576b5 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Fri, 25 Oct 2024 23:23:08 +0300 Subject: [PATCH 04/11] Prepare integration test for path verification Signed-off-by: Tero Saarni --- .../x509/ClientCertificateLookupIT.java | 119 ++++++++++-------- .../integration-test/envoy-xfcc.yaml | 52 +++++++- .../integration-test/keycloak-realm.json | 4 +- 3 files changed, 116 insertions(+), 59 deletions(-) diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java index 0ab14b1..0bacfcb 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java @@ -15,6 +15,7 @@ import java.security.KeyStore; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import org.jboss.logging.Logger; import org.junit.jupiter.api.Assertions; @@ -53,65 +54,79 @@ public class ClientCertificateLookupIT { .param("client_id", "xfcc-client") .param("grant_type", "client_credentials"); - // Generate certificates before running docker compose. static { try { // Initialize the Keycloak default crypto provider. CryptoIntegration.init(CryptoProvider.class.getClassLoader()); - // Generate certificates. - logger.info("Generating certificates..."); - - Credential serverCa = new Credential().subject("CN=server-ca"); - Credential keycloak = new Credential().subject("CN=keycloak") - .issuer(serverCa) - .subjectAltName("DNS:keycloak.127.0.0.1.nip.io"); - - Credential clientCa = new Credential().subject("CN=client-ca"); - Credential client = new Credential().subject("CN=client") - .issuer(clientCa); - - Credential untrustedCa = new Credential().subject("CN=untrusted-ca"); - Credential untrusted = new Credential().subject("CN=untrusted-client") - .issuer(untrustedCa); - - // Save certificates to disk for Envoy container to use. - if (!Files.exists(targetDir)) { - Files.createDirectories(targetDir); - } - - serverCa.writeCertificatesAsPem(targetDir.resolve("server-ca.pem")); - clientCa.writeCertificatesAsPem(targetDir.resolve("client-ca.pem")); - - keycloak.writeCertificatesAsPem(targetDir.resolve("keycloak.pem")); - keycloak.writePrivateKeyAsPem(targetDir.resolve("keycloak-key.pem")); - - client.writeCertificatesAsPem(targetDir.resolve("client.pem")); - client.writePrivateKeyAsPem(targetDir.resolve("client-key.pem")); - - untrusted.writeCertificatesAsPem(targetDir.resolve("untrusted-client.pem")); - untrusted.writePrivateKeyAsPem(targetDir.resolve("untrusted-client-key.pem")); - - // Store certificates also to truststore and keystore for the test code to use. - serverCaStore = KeyStore.getInstance("PKCS12"); - serverCaStore.load(null, null); - serverCaStore.setCertificateEntry("server-ca", serverCa.getCertificate()); - - trustedClientStore = KeyStore.getInstance("PKCS12"); - trustedClientStore.load(null, null); - trustedClientStore.setCertificateEntry("client", client.getCertificate()); - trustedClientStore.setKeyEntry("client", client.getPrivateKey(), "password".toCharArray(), - new java.security.cert.Certificate[] { client.getCertificate() }); - - untrustedClientStore = KeyStore.getInstance("PKCS12"); - untrustedClientStore.load(null, null); - untrustedClientStore.setCertificateEntry("untrusted-client", untrusted.getCertificate()); - untrustedClientStore.setKeyEntry("untrusted-client", untrusted.getPrivateKey(), "password".toCharArray(), - new java.security.cert.Certificate[] { untrusted.getCertificate() }); - + generateCertificates(); } catch (Exception e) { - throw new RuntimeException("Failed to create target directory", e); + throw new RuntimeException("Failed to create certificates", e); } + } + + /** + * Generate certificates. + * + * Note: + * Certificates are needed before running docker compose since Keycloak and Envoy will pick them up only at startup. + */ + private static void generateCertificates() throws Exception { + logger.info("Generating certificates..."); + + if (!Files.exists(targetDir)) { + Files.createDirectories(targetDir); + } + + Credential serverCa = new Credential().subject("CN=server-ca"); + Credential clientCa = new Credential().subject("CN=client-ca"); + Credential untrustedCa = new Credential().subject("CN=untrusted-ca"); + + serverCa.writeCertificatesAsPem(targetDir.resolve("server-ca.pem")); + clientCa.writeCertificatesAsPem(targetDir.resolve("client-ca.pem")); + + new Credential().subject("CN=keycloak") + .issuer(serverCa) + .subjectAltNames(Arrays.asList( + "DNS:keycloak.127.0.0.1.nip.io", "DNS:keycloak-https.127.0.0.1.nip.io")) + .writeCertificatesAsPem(targetDir.resolve("keycloak.pem")) + .writePrivateKeyAsPem(targetDir.resolve("keycloak-key.pem")); + + Credential client = new Credential().subject("CN=client") + .issuer(clientCa) + .writeCertificatesAsPem(targetDir.resolve("client.pem")) + .writePrivateKeyAsPem(targetDir.resolve("client-key.pem")); + + Credential untrusted = new Credential().subject("CN=untrusted-client") + .issuer(untrustedCa) + .writeCertificatesAsPem(targetDir.resolve("untrusted-client.pem")) + .writePrivateKeyAsPem(targetDir.resolve("untrusted-client-key.pem")); + + new Credential().subject("CN=authorized-client").issuer(clientCa) + .writeCertificatesAsPem(targetDir.resolve("authorized-client.pem")) + .writePrivateKeyAsPem(targetDir.resolve("authorized-client-key.pem")); + + new Credential().subject("CN=unauthorized-client") + .issuer(clientCa) + .writeCertificatesAsPem(targetDir.resolve("unauthorized-client.pem")) + .writePrivateKeyAsPem(targetDir.resolve("unauthorized-client-key.pem")); + + // Store certificates also to truststore and keystore for the test code to use. + serverCaStore = KeyStore.getInstance("PKCS12"); + serverCaStore.load(null, null); + serverCaStore.setCertificateEntry("server-ca", serverCa.getCertificate()); + + trustedClientStore = KeyStore.getInstance("PKCS12"); + trustedClientStore.load(null, null); + trustedClientStore.setCertificateEntry("client", client.getCertificate()); + trustedClientStore.setKeyEntry("client", client.getPrivateKey(), "password".toCharArray(), + new java.security.cert.Certificate[] { client.getCertificate() }); + + untrustedClientStore = KeyStore.getInstance("PKCS12"); + untrustedClientStore.load(null, null); + untrustedClientStore.setCertificateEntry("untrusted-client", untrusted.getCertificate()); + untrustedClientStore.setKeyEntry("untrusted-client", untrusted.getPrivateKey(), "password".toCharArray(), + new java.security.cert.Certificate[] { untrusted.getCertificate() }); } diff --git a/src/test/resources/integration-test/envoy-xfcc.yaml b/src/test/resources/integration-test/envoy-xfcc.yaml index 873fa0f..0acdbb5 100644 --- a/src/test/resources/integration-test/envoy-xfcc.yaml +++ b/src/test/resources/integration-test/envoy-xfcc.yaml @@ -7,11 +7,13 @@ admin: port_value: 9901 static_resources: clusters: - - name: mycluster + + # Cluster for Keycloak's HTTP port (no TLS between Envoy and Keycloak) + - name: keycloak type: STRICT_DNS connect_timeout: 5s load_assignment: - cluster_name: mycluster + cluster_name: keycloak endpoints: - lb_endpoints: - endpoint: @@ -19,6 +21,34 @@ static_resources: socket_address: address: keycloak port_value: 8080 + + # Cluster for Keycloak's HTTPS port, using client cert for Envoy that is authorized to send XFCC. + - name: keycloak-https-with-authorized-client-cert + type: STRICT_DNS + connect_timeout: 5s + load_assignment: + cluster_name: keycloak-https-with-authorized-client-cert + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: keycloak + port_value: 8443 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + certificate_chain: + filename: "/input/target/certs/authorized-client.pem" + private_key: + filename: "/input/target/certs/authorized-client-key.pem" + validation_context: + trusted_ca: + filename: "/input/target/certs/server-ca.pem" + listeners: - name: http address: @@ -43,14 +73,26 @@ static_resources: route_config: name: myroute virtual_hosts: - - name: myupstream + + # Virtual host for connecting to Keycloak's HTTP port (no TLS between Envoy and Keycloak) + - name: keycloak + domains: + - "keycloak.127.0.0.1.nip.io:8443" + routes: + - match: + prefix: "/" + route: + cluster: keycloak + + # Virtual host for connecting to Keycloak's HTTPS port, using client cert for Envoy that is authorized to send XFCC. + - name: keycloak-https domains: - - "*" + - "keycloak-https.127.0.0.1.nip.io:8443" routes: - match: prefix: "/" route: - cluster: mycluster + cluster: keycloak-https-with-authorized-client-cert forward_client_cert_details: SANITIZE_SET set_current_client_cert_details: diff --git a/src/test/resources/integration-test/keycloak-realm.json b/src/test/resources/integration-test/keycloak-realm.json index 67b83d7..180451c 100644 --- a/src/test/resources/integration-test/keycloak-realm.json +++ b/src/test/resources/integration-test/keycloak-realm.json @@ -13,8 +13,8 @@ "serviceAccountsEnabled": true, "attributes": { "post.logout.redirect.uris": "https://keycloak.127.0.0.1.nip.io:8443/*", - "x509.allow.regex.pattern.comparison": false, - "x509.subjectdn": "CN=client" + "x509.allow.regex.pattern.comparison": true, + "x509.subjectdn": "(.*?)(?:$)" } } ] From 8733df0a014ec938d44fb5b6b94cccf81f03b5c2 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Mon, 28 Oct 2024 16:54:39 +0200 Subject: [PATCH 05/11] Compile for Keycloak 22 --- pom.xml | 17 ++++++++++++++++- .../x509/ClientCertificateLookupIT.java | 12 +++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index d0af1f0..5cfffd4 100644 --- a/pom.xml +++ b/pom.xml @@ -13,13 +13,15 @@ UTF-8 - 21 + + 17 26.0.2 5.11.3 6.2.10.Final + 3.0.6.Final 0.4.0 1.4.0 3.4.0 @@ -55,6 +57,13 @@ ${keycloak.version} provided + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + @@ -93,6 +102,12 @@ ${apache.commons.exec.version} test + + org.jboss.logmanager + jboss-logmanager + ${jboss.logmanager.version} + test + diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java index 0bacfcb..96f1593 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java @@ -8,7 +8,6 @@ */ package io.github.nordix.keycloak.services.x509; -import java.io.StringReader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -25,9 +24,10 @@ import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.crypto.CryptoProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + import fi.protonode.certy.Credential; -import jakarta.json.Json; -import jakarta.json.JsonObject; import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; @@ -172,8 +172,10 @@ public void testAuthenticateWithCert() throws Exception { Response response = target.request().post(Entity.form(form)); String responseBody = response.readEntity(String.class); Assertions.assertEquals(200, response.getStatus(), "Failed to fetch token. Response=" + responseBody); - JsonObject obj = Json.createReader(new StringReader(responseBody)).readObject(); - Assertions.assertTrue(obj.containsKey("access_token"), "Response does not contain access_token"); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode obj = mapper.readTree(responseBody); + Assertions.assertTrue(obj.has("access_token"), "Response does not contain access_token"); } @Test From 2ef7dee39d94c5089a3cccf8c27e183eaceb5668 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Mon, 28 Oct 2024 16:56:16 +0200 Subject: [PATCH 06/11] IT: use vhost that triggers envoy to use client cert --- .../keycloak/services/x509/ClientCertificateLookupIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java index 96f1593..21e58c5 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java @@ -39,7 +39,7 @@ */ public class ClientCertificateLookupIT { - private static final String BASE_URL = "https://keycloak.127.0.0.1.nip.io:8443"; + private static final String BASE_URL = "https://keycloak-https.127.0.0.1.nip.io:8443"; private static Logger logger = Logger.getLogger(ClientCertificateLookupIT.class); From b3917372b23734d537965748c92efeb9245ac123 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Mon, 28 Oct 2024 16:56:53 +0200 Subject: [PATCH 07/11] Use command line params compatible with Keycloak 22 --- docker-compose.yaml | 27 ++++++++++++++++--- .../x509/ClientCertificateLookupIT.java | 7 +++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 909b5bc..1e606ae 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,12 +20,31 @@ services: - | /opt/keycloak/bin/kc.sh import --file /input/src/test/resources/integration-test/keycloak-realm.json /opt/keycloak/bin/kc.sh start \ - --log-level=INFO,io.github.nordix.keycloak.services.x509:debug \ - --spi-x509cert-lookup-provider=envoy \ --https-certificate-file=/input/target/certs/keycloak.pem \ --https-certificate-key-file=/input/target/certs/keycloak-key.pem \ - --truststore-paths=/input/target/certs/client-ca.pem - # --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy-client" ]]' \ + --https-trust-store-file=/input/target/certs/client-ca-truststore.p12 \ + --https-trust-store-password=password \ + --https-client-auth=request \ + --spi-x509cert-lookup-provider=envoy \ + --spi-x509cert-lookup-envoy-cert-path-verify='[[\"CN=authorized-client\"]]' \ + --log-level=INFO,io.github.nordix.keycloak.services.x509:debug + + # Notes: + # + # - Kecyloak 22 does not support + # --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy-client" ]]' + # The parameter must be quoted as follows:: + # --spi-x509cert-lookup-envoy-cert-path-verify='[[\"CN=authorized-client\"]]' + # + # This is possibly related to https://github.com/keycloak/keycloak/pull/22585 + # + # - Keycloak 22 does not support PEM format for + # --https-trust-store-file=/input/target/certs/client-ca.pem + # even though following guide indicated it should work + # https://www.keycloak.org/nightly/server/mutual-tls#_using_a_dedicated_truststore_for_mtls + # + # Keycloak 26 and newer suppport also following + # --truststore-paths=/input/target/certs/client-ca.pem environment: - KEYCLOAK_ADMIN=admin diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java index 21e58c5..dd232ed 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java @@ -128,6 +128,13 @@ private static void generateCertificates() throws Exception { untrustedClientStore.setKeyEntry("untrusted-client", untrusted.getPrivateKey(), "password".toCharArray(), new java.security.cert.Certificate[] { untrusted.getCertificate() }); + + // Create a truststore for old Keycloak versions that do not support PEM and write it to a file. + KeyStore truststoreForClientVerification = KeyStore.getInstance("PKCS12"); + truststoreForClientVerification.load(null, null); + truststoreForClientVerification.setCertificateEntry("client-ca", clientCa.getCertificate()); + truststoreForClientVerification.store(Files.newOutputStream(targetDir.resolve("client-ca-truststore.p12")), + "password".toCharArray()); } @RegisterExtension From 3ca7bb498f05c8c6dd7c1409209c74643cffb51e Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Mon, 28 Oct 2024 18:28:15 +0200 Subject: [PATCH 08/11] Pass the path parameter again in normal quotes --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 1e606ae..9696eb1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,7 +26,7 @@ services: --https-trust-store-password=password \ --https-client-auth=request \ --spi-x509cert-lookup-provider=envoy \ - --spi-x509cert-lookup-envoy-cert-path-verify='[[\"CN=authorized-client\"]]' \ + --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=authorized-client" ]]' \ --log-level=INFO,io.github.nordix.keycloak.services.x509:debug # Notes: From 958655f17d8d5f781b75192c42aa0a6e4c95d453 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Wed, 30 Oct 2024 18:49:34 +0200 Subject: [PATCH 09/11] refactoring --- docker-compose.yaml | 33 +-- .../EnvoyProxySslClientCertificateLookup.java | 198 +++++++++------- ...roxySslClientCertificateLookupFactory.java | 20 +- .../x509/ClientCertificateLookupIT.java | 222 ++++++++++++------ .../x509/ClientCertificateLookupTest.java | 76 ++++-- .../services/x509/DockerComposeExtension.java | 9 +- .../keycloak/services/x509/Helpers.java | 83 +++++++ .../services/x509/LoggingExtension.java | 39 +++ .../services/x509/PathValidationTest.java | 179 -------------- .../services/x509/PathVericationTest.java | 195 +++++++++++++++ .../keycloak/services/x509/ScopeImpl.java | 3 +- .../integration-test/envoy-xfcc.yaml | 30 +-- .../integration-test/keycloak-realm.json | 4 +- src/test/resources/logging.properties | 7 +- 14 files changed, 682 insertions(+), 416 deletions(-) create mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/Helpers.java create mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/LoggingExtension.java delete mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java create mode 100644 src/test/java/io/github/nordix/keycloak/services/x509/PathVericationTest.java diff --git a/docker-compose.yaml b/docker-compose.yaml index 9696eb1..36181d1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,21 +14,7 @@ services: keycloak: image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} - entrypoint: /bin/bash - command: - - -cxe - - | - /opt/keycloak/bin/kc.sh import --file /input/src/test/resources/integration-test/keycloak-realm.json - /opt/keycloak/bin/kc.sh start \ - --https-certificate-file=/input/target/certs/keycloak.pem \ - --https-certificate-key-file=/input/target/certs/keycloak-key.pem \ - --https-trust-store-file=/input/target/certs/client-ca-truststore.p12 \ - --https-trust-store-password=password \ - --https-client-auth=request \ - --spi-x509cert-lookup-provider=envoy \ - --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=authorized-client" ]]' \ - --log-level=INFO,io.github.nordix.keycloak.services.x509:debug - + # # Notes: # # - Kecyloak 22 does not support @@ -45,6 +31,22 @@ services: # # Keycloak 26 and newer suppport also following # --truststore-paths=/input/target/certs/client-ca.pem + # + + entrypoint: /bin/bash + command: + - -cxe + - | + /opt/keycloak/bin/kc.sh import --file /input/src/test/resources/integration-test/keycloak-realm.json + /opt/keycloak/bin/kc.sh start \ + --https-certificate-file=/input/target/certs/keycloak.pem \ + --https-certificate-key-file=/input/target/certs/keycloak-key.pem \ + --https-trust-store-file=/input/target/certs/client-ca-truststore.p12 \ + --https-trust-store-password=password \ + --https-client-auth=request \ + --spi-x509cert-lookup-provider=envoy \ + --spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy-client" ]]' \ + --log-level=INFO,io.github.nordix.keycloak.services.x509:debug environment: - KEYCLOAK_ADMIN=admin @@ -58,4 +60,5 @@ services: # Expose Keycloak's HTTPS port to allow test suite to do direct requests. ports: + - "10080:8080" - "10443:8443" diff --git a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java index cbddec3..4bb5bb5 100644 --- a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java +++ b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookup.java @@ -23,54 +23,113 @@ import org.keycloak.services.x509.X509ClientCertificateLookup; /** + * Envoy X509 client certificate lookup. + * * Extracts the client certificate chain from the HTTP request forwarded by Envoy. */ public class EnvoyProxySslClientCertificateLookup implements X509ClientCertificateLookup { private static Logger logger = Logger.getLogger(EnvoyProxySslClientCertificateLookup.class); - protected final static String XFCC_HEADER = "x-forwarded-client-cert"; - protected final static String XFCC_HEADER_CERT_KEY = "Cert"; - protected final static String XFCC_HEADER_CHAIN_KEY = "Chain"; + protected static final String XFCC_HEADER = "x-forwarded-client-cert"; + protected static final String XFCC_HEADER_CERT_KEY = "Cert"; + protected static final String XFCC_HEADER_CHAIN_KEY = "Chain"; // Each element in the list is a list of subject names expected in the client certificate chain. // - private List> validCertPaths = null; + private List> verifyCertPaths = null; - /** - * Constructor for creating an instance of EnvoyProxySslClientCertificateLookup. - */ - public EnvoyProxySslClientCertificateLookup() { + EnvoyProxySslClientCertificateLookup(List> verifyCertPaths) { + this.verifyCertPaths = verifyCertPaths; } - /** - * Constructor for creating an instance of EnvoyProxySslClientCertificateLookup. - * - * @param validCertPaths The certificate paths to validate the client certificate chain. - */ - EnvoyProxySslClientCertificateLookup(List> validCertPaths) { - this.validCertPaths = validCertPaths; + @Override + public void close() { + // Intentionally left empty. } @Override - public void close() { + public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws GeneralSecurityException { + String xfcc = httpRequest.getHttpHeaders().getRequestHeaders().getFirst(XFCC_HEADER); + + // Choose between basic XFCC extraction and extraction with client cert path verification. + if (verifyCertPaths == null) { + return extractCertificateChainFromXfcc(xfcc); + } else { + return extractWithPathVerify(httpRequest, xfcc); + } + } + + public X509Certificate[] extractWithPathVerify(HttpRequest httpRequest, String xfcc) { + // Get TLS layer client certificate. + X509Certificate[] clientChainFromTls = httpRequest.getClientCertificateChain(); + + // Check if the request was sent over TLS. + if (clientChainFromTls == null || clientChainFromTls.length == 0) { + logger.debug("No client certificate chain found in the TLS layer."); + return null; + } + + // No valid paths configured: fallback to TLS layer certificate (this disables XFCC lookup) + if (verifyCertPaths.isEmpty()) { + logger.debugv("Using client certificate from TLS layer: subject={0} chain length={1}", + clientChainFromTls[0].getSubjectX500Principal(), + clientChainFromTls.length); + return clientChainFromTls; + } + + // Is request coming from Envoy? + boolean isEnvoy = checkClientCertPath(clientChainFromTls); + + // XFCC is not present. + if (xfcc == null) { + // 1. Request from Envoy but no XFCC header: do not return Envoy's client certificate to avoid impersonation. + // 2. Request not from Envoy: return the client certificate chain from the TLS layer, if available. + // This allows clients within Envoy's perimeter to make direct requests using their own client certificate + // without going through Envoy. + return isEnvoy ? null : clientChainFromTls; + } + + // XFCC is present. + + // Request is coming from Envoy: extract the client certificate chain from the XFCC header. + if (isEnvoy) { + X509Certificate[] clientChainFromXfcc = extractCertificateChainFromXfcc(xfcc); + if (clientChainFromXfcc != null && clientChainFromXfcc.length > 0) { + logger.debugv("Using client certificate from x-forwarded-client-cert: subject={0} chain length={1}", + clientChainFromXfcc[0].getSubjectX500Principal(), clientChainFromXfcc.length); + } else { + logger.debug("No client certificate chain found in x-forwarded-client-cert header."); + } + return clientChainFromXfcc; + } + + // Request is not from Envoy but XFCC is present. + + // Clients sending requests directly should never send XFCC headers: log a warning and ignore the header. + logger.infov( + "Ignoring x-forwarded-client-cert from client that does not match configured paths. " + + "subject={0}, cert-path-verify={1}", + clientChainFromTls[0].getSubjectX500Principal().getName(), verifyCertPaths); + + return null; } /** * Extracts the client certificate chain from the HTTP request forwarded by Envoy. * - * The Envoy XFCC header value is a comma (",") separated string. - * Each substring is an XFCC element, which holds information added by a single proxy. - * Each XFCC element is a semicolon (";") separated list of key-value pairs. + * The Envoy XFCC header value is a comma (",") separated string. Each substring is an XFCC element, which holds + * information added by a single proxy. Each XFCC element is a semicolon (";") separated list of key-value pairs. * Each key-value pair is separated by an equal sign ("="). * * Example: * - * x-forwarded-client-cert: key1="url encoded value 1";key2="url encoded value 2";... + * x-forwarded-client-cert: key1="url encoded value 1";key2="url encoded value 2";... * * Following keys are supported by this implementation: * * 1. Cert - The entire client certificate in URL encoded PEM format. + * * 2. Chain - The entire client certificate chain (including the leaf certificate) in URL encoded PEM format. * * For Envoy documentation, see @@ -79,19 +138,9 @@ public void close() { * @param httpRequest The HTTP request forwarded by Envoy. * @return The client certificate chain extracted from the HTTP request. */ - @Override - public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws GeneralSecurityException { - // Before processing the XFCC header: - // 1. Check if TLS level authorization is configured. - // 2. Check if the TLS level client certificate chain matches the configured valid certificate paths. - if (validCertPaths != null && !validCertPaths.isEmpty() && !xfccAuthorized(httpRequest)) { - // Request is not coming from authorized client, fall back to the client certificate chain in the TLS layer. - logger.debug("The client certificate chain does not match the configured valid certificate paths. Falling back to the TLS layer client certificate chain."); - return httpRequest.getClientCertificateChain(); - } - - String xfcc = httpRequest.getHttpHeaders().getRequestHeaders().getFirst(XFCC_HEADER); + public X509Certificate[] extractCertificateChainFromXfcc(String xfcc) { if (xfcc == null) { + logger.debug("No x-forwarded-client-cert header found."); return null; } @@ -106,73 +155,62 @@ public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws Gen X509Certificate[] certs = null; - StringTokenizer st = new StringTokenizer(xfcc, ";"); - while (st.hasMoreTokens()) { - String token = st.nextToken(); - int index = token.indexOf("="); - if (index != -1) { - String key = token.substring(0, index).trim(); - String value = token.substring(index + 1).trim(); - - if (key.equals(XFCC_HEADER_CHAIN_KEY)) { - // Chain contains the entire chain including the leaf certificate so we can stop processing the header. - certs = PemUtils.decodeCertificates(decodeValue(value)); - break; - } else if (key.equals(XFCC_HEADER_CERT_KEY)) { - // Cert contains only the leaf certificate. We need to continue processing the header in case Chain is present. - certs = PemUtils.decodeCertificates(decodeValue(value)); + try { + StringTokenizer st = new StringTokenizer(xfcc, ";"); + while (st.hasMoreTokens()) { + String token = st.nextToken(); + int index = token.indexOf("="); + if (index != -1) { + String key = token.substring(0, index).trim(); + String value = token.substring(index + 1).trim(); + + if (key.equals(XFCC_HEADER_CHAIN_KEY)) { + // Chain contains the entire chain including the leaf certificate so we can stop processing the header. + certs = PemUtils.decodeCertificates(decodeValue(value)); + break; + } else if (key.equals(XFCC_HEADER_CERT_KEY)) { + // Cert contains only the leaf certificate. We need to continue processing the header in case + // Chain is also present. + certs = PemUtils.decodeCertificates(decodeValue(value)); + } } } - } - logger.debugv("Returning certificate chain with {0} certificates", certs != null ? certs.length : 0); - if (certs != null && logger.isDebugEnabled()) { - for (X509Certificate cert : certs) { - logger.debugv("Subject: {0}, Issuer: {1}", cert.getSubjectX500Principal(), cert.getIssuerX500Principal()); - } + } catch (Exception e) { + logger.warnv("Failed to extract client certificate from x-forwarded-client-cert header: {0}", + e.getMessage()); + throw new SecurityException("Failed to extract client certificate from x-forwarded-client-cert header", e); } return certs; } - private boolean xfccAuthorized(HttpRequest httpRequest) { - X509Certificate[] clientChain = httpRequest.getClientCertificateChain(); - if (clientChain == null || clientChain.length == 0) { - logger.debug("No client certificate chain found in the TLS layer."); - return false; - } - - return isClientCertPathValid(clientChain); - } - /** - * Validates the client certificate chain against the configured valid certificate paths. + * Verifies the client certificate chain against the configured expected certificate paths. + * + * Path is a list of subject names from the client certificate chain, + * starting from the leaf certificate but excluding the root certificate. */ - private boolean isClientCertPathValid(X509Certificate[] clientCerts) { - if (validCertPaths.isEmpty()) { - logger.debug("Skipping client certificate chain validation as no certificate paths are configured."); - return true; - } - + private boolean checkClientCertPath(X509Certificate[] clientChain) { // Create a list of subject names from the client certificate chain. - List path = new ArrayList<>(); - for (X509Certificate cert : clientCerts) { - path.add(cert.getSubjectX500Principal()); + List receivedPath = new ArrayList<>(); + for (X509Certificate cert : clientChain) { + receivedPath.add(cert.getSubjectX500Principal()); } - logger.debugv("Client certificate chain path: {0}", path); + logger.debugv("Client certificate path: {0}", receivedPath); - for (List validPath : validCertPaths) { - logger.debugv("Expected certificate path: {0}", validPath); + for (List expectedPath : verifyCertPaths) { + logger.debugv("Expected certificate path: {0}", expectedPath); - // Valid path cannot be longer than the client certificate chain. - if (path.size() < validPath.size()) { + // Expected path cannot be longer than the actual client certificate chain. + if (receivedPath.size() < expectedPath.size()) { continue; } boolean match = true; - for (int i = 0; i < validPath.size(); i++) { - if (!path.get(i).equals(validPath.get(i))) { + for (int i = 0; i < expectedPath.size(); i++) { + if (!receivedPath.get(i).equals(expectedPath.get(i))) { match = false; break; } diff --git a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java index 63465cb..5d21a43 100644 --- a/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java +++ b/src/main/java/io/github/nordix/keycloak/services/x509/EnvoyProxySslClientCertificateLookupFactory.java @@ -21,14 +21,12 @@ import org.keycloak.services.x509.X509ClientCertificateLookupFactory; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; - /** * Factory for creating EnvoyProxySslClientCertificateLookup instances. */ @@ -36,40 +34,44 @@ public class EnvoyProxySslClientCertificateLookupFactory implements X509ClientCe private static Logger logger = Logger.getLogger(EnvoyProxySslClientCertificateLookupFactory.class); - private final static String PROVIDER = "envoy"; + private static final String PROVIDER = "envoy"; - private List> validCertPaths; + private List> verifyCertPaths = null; @Override public void init(Scope config) { String pathsJson = config.get("cert-path-verify"); if (pathsJson != null) { - logger.debugv("Client certificate path validation configured: {0}", pathsJson); ObjectMapper mapper = new ObjectMapper(); SimpleModule module = new SimpleModule(); module.addDeserializer(X500Principal.class, new X500PrincipalDeserializer()); mapper.registerModule(module); try { - validCertPaths = mapper.readValue(pathsJson, new TypeReference>>() {}); + verifyCertPaths = mapper.readValue(pathsJson, new TypeReference>>() { + }); } catch (Exception e) { throw new RuntimeException("Failed to parse cert-paths", e); } - } } @Override public X509ClientCertificateLookup create(KeycloakSession session) { - return new EnvoyProxySslClientCertificateLookup(validCertPaths); + logger.debugv("Creating Envoy X509 client certificate lookup: certificate path verification {0} {1}", + verifyCertPaths == null ? "disabled" : "enabled", + verifyCertPaths == null ? "" : verifyCertPaths); + return new EnvoyProxySslClientCertificateLookup(verifyCertPaths); } @Override public void postInit(KeycloakSessionFactory factory) { + // Intentionally left empty. } @Override public void close() { + // Intentionally left empty. } @Override @@ -79,7 +81,7 @@ public String getId() { public class X500PrincipalDeserializer extends JsonDeserializer { @Override - public X500Principal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { + public X500Principal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { return new X500Principal(p.getValueAsString()); } } diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java index dd232ed..697e702 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupIT.java @@ -8,10 +8,13 @@ */ package io.github.nordix.keycloak.services.x509; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -20,6 +23,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.crypto.CryptoProvider; @@ -37,22 +41,22 @@ /** * Integration tests with Envoy and Keycloak. */ +@ExtendWith(LoggingExtension.class) public class ClientCertificateLookupIT { - private static final String BASE_URL = "https://keycloak-https.127.0.0.1.nip.io:8443"; + private static final String ENVOY_BASE_URL = "https://keycloak.127.0.0.1.nip.io:8443"; + private static final String KEYCLOAK_DIRECT_HTTPS_BASE_URL = "https://keycloak.127.0.0.1.nip.io:10443"; + private static final String KEYCLOAK_DIRECT_HTTP_BASE_URL = "http://keycloak.127.0.0.1.nip.io:10080"; private static Logger logger = Logger.getLogger(ClientCertificateLookupIT.class); private static String baseDir = System.getProperty("user.dir"); private static Path targetDir = Paths.get(baseDir, "target/certs"); - private static KeyStore serverCaStore; - private static KeyStore trustedClientStore; - private static KeyStore untrustedClientStore; + private static Credential serverCa; + private static Credential clientCa; - private static Form form = new Form() - .param("client_id", "xfcc-client") - .param("grant_type", "client_credentials"); + private static Form form = new Form().param("client_id", "xfcc-client").param("grant_type", "client_credentials"); static { try { @@ -68,8 +72,8 @@ public class ClientCertificateLookupIT { /** * Generate certificates. * - * Note: - * Certificates are needed before running docker compose since Keycloak and Envoy will pick them up only at startup. + * Note: Certificates are needed before running docker compose since Keycloak and Envoy will pick them up only at + * startup. */ private static void generateCertificates() throws Exception { logger.info("Generating certificates..."); @@ -78,63 +82,22 @@ private static void generateCertificates() throws Exception { Files.createDirectories(targetDir); } - Credential serverCa = new Credential().subject("CN=server-ca"); - Credential clientCa = new Credential().subject("CN=client-ca"); - Credential untrustedCa = new Credential().subject("CN=untrusted-ca"); + serverCa = new Credential().subject("CN=server-ca").writeCertificatesAsPem(targetDir.resolve("server-ca.pem")); + clientCa = new Credential().subject("CN=client-ca").writeCertificatesAsPem(targetDir.resolve("client-ca.pem")); - serverCa.writeCertificatesAsPem(targetDir.resolve("server-ca.pem")); - clientCa.writeCertificatesAsPem(targetDir.resolve("client-ca.pem")); + // Create a truststore for old Keycloak versions that do not support PEM and write it to a file. + Helpers.newTrustStore(clientCa).store(Files.newOutputStream(targetDir.resolve("client-ca-truststore.p12")), + Helpers.STORE_PASSWORD.toCharArray()); - new Credential().subject("CN=keycloak") - .issuer(serverCa) - .subjectAltNames(Arrays.asList( - "DNS:keycloak.127.0.0.1.nip.io", "DNS:keycloak-https.127.0.0.1.nip.io")) + new Credential().subject("CN=keycloak").issuer(serverCa) + .subjectAltNames(Arrays.asList("DNS:keycloak.127.0.0.1.nip.io", "DNS:keycloak-https.127.0.0.1.nip.io")) .writeCertificatesAsPem(targetDir.resolve("keycloak.pem")) .writePrivateKeyAsPem(targetDir.resolve("keycloak-key.pem")); - Credential client = new Credential().subject("CN=client") - .issuer(clientCa) - .writeCertificatesAsPem(targetDir.resolve("client.pem")) - .writePrivateKeyAsPem(targetDir.resolve("client-key.pem")); - - Credential untrusted = new Credential().subject("CN=untrusted-client") - .issuer(untrustedCa) - .writeCertificatesAsPem(targetDir.resolve("untrusted-client.pem")) - .writePrivateKeyAsPem(targetDir.resolve("untrusted-client-key.pem")); - - new Credential().subject("CN=authorized-client").issuer(clientCa) - .writeCertificatesAsPem(targetDir.resolve("authorized-client.pem")) - .writePrivateKeyAsPem(targetDir.resolve("authorized-client-key.pem")); - - new Credential().subject("CN=unauthorized-client") - .issuer(clientCa) - .writeCertificatesAsPem(targetDir.resolve("unauthorized-client.pem")) - .writePrivateKeyAsPem(targetDir.resolve("unauthorized-client-key.pem")); - - // Store certificates also to truststore and keystore for the test code to use. - serverCaStore = KeyStore.getInstance("PKCS12"); - serverCaStore.load(null, null); - serverCaStore.setCertificateEntry("server-ca", serverCa.getCertificate()); - - trustedClientStore = KeyStore.getInstance("PKCS12"); - trustedClientStore.load(null, null); - trustedClientStore.setCertificateEntry("client", client.getCertificate()); - trustedClientStore.setKeyEntry("client", client.getPrivateKey(), "password".toCharArray(), - new java.security.cert.Certificate[] { client.getCertificate() }); - - untrustedClientStore = KeyStore.getInstance("PKCS12"); - untrustedClientStore.load(null, null); - untrustedClientStore.setCertificateEntry("untrusted-client", untrusted.getCertificate()); - untrustedClientStore.setKeyEntry("untrusted-client", untrusted.getPrivateKey(), "password".toCharArray(), - new java.security.cert.Certificate[] { untrusted.getCertificate() }); - + new Credential().subject("CN=envoy-client").issuer(clientCa) + .writeCertificatesAsPem(targetDir.resolve("envoy-client.pem")) + .writePrivateKeyAsPem(targetDir.resolve("envoy-client-key.pem")); - // Create a truststore for old Keycloak versions that do not support PEM and write it to a file. - KeyStore truststoreForClientVerification = KeyStore.getInstance("PKCS12"); - truststoreForClientVerification.load(null, null); - truststoreForClientVerification.setCertificateEntry("client-ca", clientCa.getCertificate()); - truststoreForClientVerification.store(Files.newOutputStream(targetDir.resolve("client-ca-truststore.p12")), - "password".toCharArray()); } @RegisterExtension @@ -143,12 +106,12 @@ private static void generateCertificates() throws Exception { @BeforeAll public static void waitForKeycloak() throws Exception { // Wait for Keycloak to be ready. - WebTarget target = ClientBuilder.newBuilder().trustStore(serverCaStore).build().target(BASE_URL); + WebTarget target = newTargetNoClientAuth(ENVOY_BASE_URL); Instant startTime = Instant.now(); Duration timeout = Duration.ofMinutes(5); while (true) { - logger.infov("Checking Keycloak readiness: url={0}", BASE_URL); + logger.infov("Checking Keycloak readiness: url={0}", ENVOY_BASE_URL); try { Response response = target.request().get(); @@ -169,12 +132,21 @@ public static void waitForKeycloak() throws Exception { } } + /** + * 1. External client connects to Envoy using TLS and client certificate (CN=authorized-client). + * 2. Envoy connects to Keycloak using TLS and client certificate (CN=envoy-client). + * 3. Keycloak accepts the TLS connection, authenticated with the client certificate (CN=envoy-client). + * 4. Envoy forwards the client request to Keycloak with XFCC header set to the client certificate (CN=authorized-client). + * 5. Envoy X509 Lookup verifies that TLS level client certificate matches the configured certificate path (CN=envoy-client). + * 6. Envoy X509 Lookup returns client certificate from XFCC to Keycloak (CN=authorized-client). + * 7. Keycloak X509 Authenticator accepts request (CN=authorized-client) and returns the token. + */ @Test - public void testAuthenticateWithCert() throws Exception { - WebTarget target = ClientBuilder.newBuilder() - .trustStore(serverCaStore) - .keyStore(trustedClientStore, "password") - .build().target(BASE_URL + "/realms/xfcc/protocol/openid-connect/token"); + void testEnvoyAuthorizedClientCert() throws Exception { + Credential tlsCred = new Credential().subject("CN=authorized-client").issuer(clientCa); + + WebTarget target = newTargetWithClientAuth(ENVOY_BASE_URL + "/realms/xfcc/protocol/openid-connect/token", + tlsCred); Response response = target.request().post(Entity.form(form)); String responseBody = response.readEntity(String.class); @@ -185,16 +157,120 @@ public void testAuthenticateWithCert() throws Exception { Assertions.assertTrue(obj.has("access_token"), "Response does not contain access_token"); } + /** + * 1. External client connects to Envoy using TLS and client certificate (CN=unauthorized-client). + * 2. Envoy connects to Keycloak using TLS and client certificate (CN=envoy-client). + * 3. Keycloak accepts the TLS connection, authenticated with the client certificate (CN=envoy-client). + * 4. Envoy forwards the client request to Keycloak with XFCC header set to the client certificate (CN=unauthorized-client). + * 5. Envoy X509 Lookup verifies that TLS level client certificate matches the configured certificate path (CN=envoy-client). + * 6. Envoy X509 Lookup returns client certificate from XFCC to Keycloak (CN=unauthorized-client). + * 7. Keycloak X509 Authenticator rejects the request since it has wrong subject name (CN=authorized-client). + */ @Test - public void testFailedAuthenticateWithCert() throws Exception { - WebTarget target = ClientBuilder.newBuilder() - .trustStore(serverCaStore) - .keyStore(untrustedClientStore, "password") - .build().target(BASE_URL + "/realms/xfcc/protocol/openid-connect/token"); + void testEnvoyUnauthorizedClientCert() throws Exception { + Credential tlsCred = new Credential().subject("CN=unauthorized-client").issuer(clientCa); + + WebTarget target = newTargetWithClientAuth(ENVOY_BASE_URL + "/realms/xfcc/protocol/openid-connect/token", + tlsCred); Response response = target.request().post(Entity.form(form)); String responseBody = response.readEntity(String.class); Assertions.assertEquals(401, response.getStatus(), "Was expecting 401 Unauthorized. Response=" + responseBody); } + /** + * 1. External client connects to Envoy using plain HTTP. + * 2. Envoy connects to Keycloak using TLS and client certificate (CN=envoy-client). + * 3. Keycloak accepts the TLS connection, authenticated with the client certificate (CN=envoy-client). + * 4. Envoy forwards the client request to Keycloak with no XFCC header. + * 5. Envoy X509 Lookup verifies that TLS level client certificate matches the configured certificate path (CN=envoy-client). + * 6. Envoy X509 Lookup returns null since the request has no XFCC header even though request came from Envoy (CN=envoy-client). + * 7. Keycloak X509 Authenticator rejects the request since lookup did not return a client certificate. + */ + @Test + void testEnvoyWithoutClientCert() throws Exception { + WebTarget target = newTargetNoClientAuth(ENVOY_BASE_URL + "/realms/xfcc/protocol/openid-connect/token"); + + Response response = target.request().post(Entity.form(form)); + String responseBody = response.readEntity(String.class); + Assertions.assertEquals(401, response.getStatus(), "Was expecting 401 Unauthorized. Response=" + responseBody); + } + + /** + * 1. Cluster internal client connects to Keycloak using plain HTTP. + * 2. Client sends request with XFCC header with client certificate (CN=authorized-client). + * 3. Envoy X509 Lookup verifies if TLS level client certificate matches the configured certificate path (CN=envoy-client). + * 4. Envoy X509 Lookup returns null since the request is not over TLS. + * 5. Keycloak X509 Authenticator rejects the request since lookup did not return a client certificate. + */ + @Test + void testInternalClientHttpUnauthorizedXfcc() throws Exception { + Credential xfccCred = new Credential().subject("CN=authorized-client").issuer(clientCa); + + WebTarget target = newTargetNoClientAuth( + KEYCLOAK_DIRECT_HTTP_BASE_URL + "/realms/xfcc/protocol/openid-connect/token"); + + Response response = target.request().header("x-forwarded-client-cert", Helpers.getXfccWithCert(xfccCred)) + .post(Entity.form(form)); + String responseBody = response.readEntity(String.class); + Assertions.assertEquals(401, response.getStatus(), "Was expecting 401 Unauthorized. Response=" + responseBody); + } + + /** + * 1. Cluster internal client connects to Keycloak directly using TLS and client certificate (CN=not-envoy). + * 2. Client sends request with XFCC header with client certificate (CN=authorized-client). + * 3. Envoy X509 Lookup verified if TLS level client certificate matches the configured certificate path (CN=envoy-client). + * 4. Envoy X509 Lookup returns null since client (CN=not-envoy) is not authorized to send the XFCC header. + * 5. Keycloak X509 Authenticator rejects the request since lookup did not return a client certificate. + */ + @Test + void testInternalClientHttpsUnauthorizedXfcc() throws Exception { + Credential tlsCred = new Credential().subject("CN=not-envoy").issuer(clientCa); + Credential xfccCred = new Credential().subject("CN=authorized-client").issuer(clientCa); + + WebTarget target = newTargetWithClientAuth( + KEYCLOAK_DIRECT_HTTP_BASE_URL + "/realms/xfcc/protocol/openid-connect/token", tlsCred); + + Response response = target.request().header("x-forwarded-client-cert", Helpers.getXfccWithCert(xfccCred)) + .post(Entity.form(form)); + + String responseBody = response.readEntity(String.class); + Assertions.assertEquals(401, response.getStatus(), "Was expecting 401 Unauthorized. Response=" + responseBody); + } + + /** + * 1. Cluster internal client connects to Keycloak directly using TLS and client certificate (CN=authorized-client). + * 2. Client sends request without XFCC header. + * 3. Client X509 Lookup verifies if TLS level client certificate matches the configured certificate path (CN=envoy-client), it does not. + * 4. Client X509 Lookup returns the client certificate from TLS layer (CN=authorized-client). + * 5. Keycloak X509 Authenticator accepts request (CN=authorized-client) and returns the token. + */ + @Test + void TestInternalClientHttpsAuthorized() throws Exception { + Credential tlsCred = new Credential().subject("CN=authorized-client").issuer(clientCa); + + WebTarget target = newTargetWithClientAuth( + KEYCLOAK_DIRECT_HTTPS_BASE_URL + "/realms/xfcc/protocol/openid-connect/token", tlsCred); + + Response response = target.request().post(Entity.form(form)); + String responseBody = response.readEntity(String.class); + Assertions.assertEquals(200, response.getStatus(), "Failed to fetch token. Response=" + responseBody); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode obj = mapper.readTree(responseBody); + Assertions.assertTrue(obj.has("access_token"), "Response does not contain access_token"); + } + + // Helper methods + + static WebTarget newTargetNoClientAuth(String url) + throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException { + return ClientBuilder.newBuilder().trustStore(Helpers.newTrustStore(serverCa)).build().target(url); + } + + static WebTarget newTargetWithClientAuth(String url, Credential clientCred) + throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException { + return ClientBuilder.newBuilder().trustStore(Helpers.newTrustStore(serverCa)) + .keyStore(Helpers.newKeyStore(clientCred), Helpers.STORE_PASSWORD).build().target(url); + } } diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java index cbf50f9..4b3d450 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ClientCertificateLookupTest.java @@ -14,14 +14,18 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.crypto.CryptoProvider; import org.keycloak.http.HttpRequest; import org.keycloak.services.x509.X509ClientCertificateLookup; +import fi.protonode.certy.Credential; + /** * Unit tests for EnvoyProxySslClientCertificateLookup. */ +@ExtendWith(LoggingExtension.class) public class ClientCertificateLookupTest { private static X509ClientCertificateLookup envoyLookup = null; @@ -34,11 +38,14 @@ public static void setup() { envoyLookup = factory.create(null); } + /** + * Verify that XFCC Cert is used when Cert is present. + */ @Test - public void testCertificate() throws Exception { - // Verify that XFCC Cert is used when only Cert is present. - - String cert = "Hash=a3d0d47ddd0db8c93ea787ef2fb025ddb64f24e6a808a55e73349486e0a890be;Cert=\"-----BEGIN%20CERTIFICATE-----%0AMIIBVTCB%2FKADAgECAggX9bbmjJbJVjAKBggqhkjOPQQDAjAYMRYwFAYDVQQDEw1j%0AbGllbnQtc3ViLWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owFTET%0AMBEGA1UEAxMKeDUwOWNsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJIk%0A8XcabnGpwh2tDYlrKzQ1Z2X8SNrtBo4RypOfv7Vw2dFxMqab%2F%2FnPJZafCWkO6odO%0Ao1AVYK8hv8arz2FrLtGjMzAxMA4GA1UdDwEB%2FwQEAwIFoDAfBgNVHSMEGDAWgBQk%0A4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAKBggqhkjOPQQDAgNIADBFAiEAmIsEIqyRvFWr%0A5PDbbcOK6aOVKxUkCDUE9O27ITgTURgCICl1Hju0kFnDrTNpXHABg5dmWQ%2BD6y2L%0A7LDd0viM2OVJ%0A-----END%20CERTIFICATE-----%0A\""; + void testCertificate() throws Exception { + // + Credential client = new Credential().subject("CN=x509client"); + String cert = Helpers.getXfccWithCert(client); HttpRequest request = new HttpRequestImpl( MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", cert)); @@ -49,11 +56,15 @@ public void testCertificate() throws Exception { Assertions.assertEquals("CN=x509client", certs[0].getSubjectX500Principal().getName()); } + /** + * Verify that XFCC Chain is used when Chain is present. + */ @Test - public void testChain() throws Exception { - // Verify that XFCC Chain is used when only Chain is present. - - String chain = "Hash=a3d0d47ddd0db8c93ea787ef2fb025ddb64f24e6a808a55e73349486e0a890be;Chain=\"-----BEGIN%20CERTIFICATE-----%0AMIIBVTCB%2FKADAgECAggX9bbmjJbJVjAKBggqhkjOPQQDAjAYMRYwFAYDVQQDEw1j%0AbGllbnQtc3ViLWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owFTET%0AMBEGA1UEAxMKeDUwOWNsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJIk%0A8XcabnGpwh2tDYlrKzQ1Z2X8SNrtBo4RypOfv7Vw2dFxMqab%2F%2FnPJZafCWkO6odO%0Ao1AVYK8hv8arz2FrLtGjMzAxMA4GA1UdDwEB%2FwQEAwIFoDAfBgNVHSMEGDAWgBQk%0A4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAKBggqhkjOPQQDAgNIADBFAiEAmIsEIqyRvFWr%0A5PDbbcOK6aOVKxUkCDUE9O27ITgTURgCICl1Hju0kFnDrTNpXHABg5dmWQ%2BD6y2L%0A7LDd0viM2OVJ%0A-----END%20CERTIFICATE-----%0A-----BEGIN%20CERTIFICATE-----%0AMIIBhTCCASugAwIBAgIIF%2FW25oyTeEowCgYIKoZIzj0EAwIwFDESMBAGA1UEAxMJ%0AY2xpZW50LWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owGDEWMBQG%0AA1UEAxMNY2xpZW50LXN1Yi1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABG5K%0A2RWk6GVSgVlIccGNfRt3Iubpr6rz5j%2FbEpuB02G9LW3sg7x3uKfLQL4hNnpyJooR%0AhMuo%2FtPCaBFGAUCiYzqjYzBhMA4GA1UdDwEB%2FwQEAwIBBjAPBgNVHRMBAf8EBTAD%0AAQH%2FMB0GA1UdDgQWBBQk4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAfBgNVHSMEGDAWgBQZ%0Aa6PCs%2BstKIy2mwuHcKzkKtzBvjAKBggqhkjOPQQDAgNIADBFAiEA69pKJ%2FZ25TN6%0AINr8rutOQCC0Lczo23KijbTQrF4USmECIFb8RrXYV34rxmTaSWH37fqmvsYEo3mp%0AmJ5bu1L%2BL9Zo%0A-----END%20CERTIFICATE-----%0A\""; + void testChain() throws Exception { + Credential ca = new Credential().subject("CN=ca").ca(true); + Credential subCa = new Credential().subject("CN=client-sub-ca").ca(true).issuer(ca); + Credential client = new Credential().subject("CN=x509client").issuer(subCa); + String chain = Helpers.getXfccWithChain(client); HttpRequest request = new HttpRequestImpl( MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", chain)); @@ -65,12 +76,16 @@ public void testChain() throws Exception { Assertions.assertEquals("CN=client-sub-ca", certs[1].getSubjectX500Principal().getName()); } - + /** + * Verify that XFCC Chain is used when both Cert and Chain are present. + */ @Test - public void testCertificateAndChain() throws Exception { + void testCertificateAndChain() throws Exception { // Verify that XFCC Chain is used when both Cert and Chain are present. - - String certAndChain = "Hash=a3d0d47ddd0db8c93ea787ef2fb025ddb64f24e6a808a55e73349486e0a890be;Cert=\"-----BEGIN%20CERTIFICATE-----%0AMIIBVTCB%2FKADAgECAggX9bbmjJbJVjAKBggqhkjOPQQDAjAYMRYwFAYDVQQDEw1j%0AbGllbnQtc3ViLWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owFTET%0AMBEGA1UEAxMKeDUwOWNsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJIk%0A8XcabnGpwh2tDYlrKzQ1Z2X8SNrtBo4RypOfv7Vw2dFxMqab%2F%2FnPJZafCWkO6odO%0Ao1AVYK8hv8arz2FrLtGjMzAxMA4GA1UdDwEB%2FwQEAwIFoDAfBgNVHSMEGDAWgBQk%0A4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAKBggqhkjOPQQDAgNIADBFAiEAmIsEIqyRvFWr%0A5PDbbcOK6aOVKxUkCDUE9O27ITgTURgCICl1Hju0kFnDrTNpXHABg5dmWQ%2BD6y2L%0A7LDd0viM2OVJ%0A-----END%20CERTIFICATE-----%0A\";Chain=\"-----BEGIN%20CERTIFICATE-----%0AMIIBVTCB%2FKADAgECAggX9bbmjJbJVjAKBggqhkjOPQQDAjAYMRYwFAYDVQQDEw1j%0AbGllbnQtc3ViLWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owFTET%0AMBEGA1UEAxMKeDUwOWNsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJIk%0A8XcabnGpwh2tDYlrKzQ1Z2X8SNrtBo4RypOfv7Vw2dFxMqab%2F%2FnPJZafCWkO6odO%0Ao1AVYK8hv8arz2FrLtGjMzAxMA4GA1UdDwEB%2FwQEAwIFoDAfBgNVHSMEGDAWgBQk%0A4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAKBggqhkjOPQQDAgNIADBFAiEAmIsEIqyRvFWr%0A5PDbbcOK6aOVKxUkCDUE9O27ITgTURgCICl1Hju0kFnDrTNpXHABg5dmWQ%2BD6y2L%0A7LDd0viM2OVJ%0A-----END%20CERTIFICATE-----%0A-----BEGIN%20CERTIFICATE-----%0AMIIBhTCCASugAwIBAgIIF%2FW25oyTeEowCgYIKoZIzj0EAwIwFDESMBAGA1UEAxMJ%0AY2xpZW50LWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owGDEWMBQG%0AA1UEAxMNY2xpZW50LXN1Yi1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABG5K%0A2RWk6GVSgVlIccGNfRt3Iubpr6rz5j%2FbEpuB02G9LW3sg7x3uKfLQL4hNnpyJooR%0AhMuo%2FtPCaBFGAUCiYzqjYzBhMA4GA1UdDwEB%2FwQEAwIBBjAPBgNVHRMBAf8EBTAD%0AAQH%2FMB0GA1UdDgQWBBQk4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAfBgNVHSMEGDAWgBQZ%0Aa6PCs%2BstKIy2mwuHcKzkKtzBvjAKBggqhkjOPQQDAgNIADBFAiEA69pKJ%2FZ25TN6%0AINr8rutOQCC0Lczo23KijbTQrF4USmECIFb8RrXYV34rxmTaSWH37fqmvsYEo3mp%0AmJ5bu1L%2BL9Zo%0A-----END%20CERTIFICATE-----%0A\""; + Credential ca = new Credential().subject("CN=ca").ca(true); + Credential subCa = new Credential().subject("CN=client-sub-ca").ca(true).issuer(ca); + Credential client = new Credential().subject("CN=x509client").issuer(subCa); + String certAndChain = Helpers.getXfccWithCertAndChain(client); HttpRequest request = new HttpRequestImpl( MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", certAndChain)); @@ -82,22 +97,28 @@ public void testCertificateAndChain() throws Exception { Assertions.assertEquals("CN=client-sub-ca", certs[1].getSubjectX500Principal().getName()); } - + /** + * No XFCC header present. + */ @Test - public void testNoCertificate() throws Exception { + void testNoCertificate() throws Exception { // No XFCC header. - Assertions.assertNull(envoyLookup.getCertificateChain(new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar")))); + Assertions.assertNull( + envoyLookup.getCertificateChain(new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar")))); // No Cert or Chain value in XFCC header. Assertions.assertNull(envoyLookup.getCertificateChain(new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", "foobar")))); + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", "foobar")))); } + /** + * Multiple comma separated XFCC elements present. + */ @Test - public void testMultipleXfccElements() throws Exception { - // Multiple comma separated elements in XFCC header. - String multipleElements = "Hash=a3d0d47ddd0db8c93ea787ef2fb025ddb64f24e6a808a55e73349486e0a890be;Cert=\"-----BEGIN%20CERTIFICATE-----%0AMIIBVTCB%2FKADAgECAggX9bbmjJbJVjAKBggqhkjOPQQDAjAYMRYwFAYDVQQDEw1j%0AbGllbnQtc3ViLWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owFTET%0AMBEGA1UEAxMKeDUwOWNsaWVudDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJIk%0A8XcabnGpwh2tDYlrKzQ1Z2X8SNrtBo4RypOfv7Vw2dFxMqab%2F%2FnPJZafCWkO6odO%0Ao1AVYK8hv8arz2FrLtGjMzAxMA4GA1UdDwEB%2FwQEAwIFoDAfBgNVHSMEGDAWgBQk%0A4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAKBggqhkjOPQQDAgNIADBFAiEAmIsEIqyRvFWr%0A5PDbbcOK6aOVKxUkCDUE9O27ITgTURgCICl1Hju0kFnDrTNpXHABg5dmWQ%2BD6y2L%0A7LDd0viM2OVJ%0A-----END%20CERTIFICATE-----%0A\",Hash=a3d0d47ddd0db8c93ea787ef2fb025ddb64f24e6a808a55e73349486e0a890be;Cert=\"-----BEGIN%20CERTIFICATE-----%0AMIIBhTCCASugAwIBAgIIF%2FW25oyTeEowCgYIKoZIzj0EAwIwFDESMBAGA1UEAxMJ%0AY2xpZW50LWNhMB4XDTI0MDkxNjExNDUzM1oXDTI1MDkxNjExNDUzM1owGDEWMBQG%0AA1UEAxMNY2xpZW50LXN1Yi1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABG5K%0A2RWk6GVSgVlIccGNfRt3Iubpr6rz5j%2FbEpuB02G9LW3sg7x3uKfLQL4hNnpyJooR%0AhMuo%2FtPCaBFGAUCiYzqjYzBhMA4GA1UdDwEB%2FwQEAwIBBjAPBgNVHRMBAf8EBTAD%0AAQH%2FMB0GA1UdDgQWBBQk4w6%2F09dXWDgQXI%2FeySP%2BJ8IruTAfBgNVHSMEGDAWgBQZ%0Aa6PCs%2BstKIy2mwuHcKzkKtzBvjAKBggqhkjOPQQDAgNIADBFAiEA69pKJ%2FZ25TN6%0AINr8rutOQCC0Lczo23KijbTQrF4USmECIFb8RrXYV34rxmTaSWH37fqmvsYEo3mp%0AmJ5bu1L%2BL9Zo%0A-----END%20CERTIFICATE-----%0A\""; + void testMultipleXfccElements() throws Exception { + Credential client1 = new Credential().subject("CN=client1"); + Credential client2 = new Credential().subject("CN=client2"); + String multipleElements = Helpers.getXfccWithCert(client1) + "," + Helpers.getXfccWithCert(client2); HttpRequest request = new HttpRequestImpl( MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", multipleElements)); @@ -105,7 +126,20 @@ public void testMultipleXfccElements() throws Exception { Assertions.assertNotNull(certs); Assertions.assertEquals(1, certs.length); - Assertions.assertEquals("CN=x509client", certs[0].getSubjectX500Principal().getName()); + Assertions.assertEquals("CN=client1", certs[0].getSubjectX500Principal().getName()); + } + + /** + * Corrupted certificate in XFCC header. + */ + @Test + void testCorruptedCertificate() throws Exception { + HttpRequest request = new HttpRequestImpl( + MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", "Cert=\"foobar\"")); + + Assertions.assertThrows(SecurityException.class, () -> { + envoyLookup.getCertificateChain(request); + }); } } diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java b/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java index b99a2f0..9343468 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/DockerComposeExtension.java @@ -36,8 +36,8 @@ public class DockerComposeExtension implements BeforeAllCallback, AfterAllCallba } /** - * Start Docker Compose before all tests but do not wait for it to complete. - * That allows the logs to be displayed in the console in parallel with the test execution. + * Start Docker Compose before all tests but do not wait for it to complete. That allows the logs to be displayed in + * the console in parallel with the test execution. */ @Override public void beforeAll(ExtensionContext context) throws Exception { @@ -55,9 +55,8 @@ public void afterAll(ExtensionContext context) throws Exception { /** * Run a command on a subprocess. * - * Note: - * Use apache-commons-exec since ProcessBuilder had problems with output redirection - * (output stopped in the middle even if the process was still running). + * Note: Use apache-commons-exec since ProcessBuilder had problems with output redirection (output stopped in the + * middle even if the process was still running). */ private void run(String command, boolean waitForCompletion, String errorMessage) throws Exception { CommandLine cmdLine = CommandLine.parse(command); diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/Helpers.java b/src/test/java/io/github/nordix/keycloak/services/x509/Helpers.java new file mode 100644 index 0000000..d5626f8 --- /dev/null +++ b/src/test/java/io/github/nordix/keycloak/services/x509/Helpers.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2024 OpenInfra Foundation Europe and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution, and is available at + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.nordix.keycloak.services.x509; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import org.keycloak.Config.Scope; +import org.keycloak.services.x509.X509ClientCertificateLookup; + +import fi.protonode.certy.Credential; + +public class Helpers { + + static final String STORE_PASSWORD = "password"; + + /** + * Create new instance of Envoy X509 client certificate lookup implementation. + */ + static X509ClientCertificateLookup createLookupWithConfig(String configJson) { + Scope config = ScopeImpl.fromPairs("cert-path-verify", configJson); + EnvoyProxySslClientCertificateLookupFactory factory = new EnvoyProxySslClientCertificateLookupFactory(); + factory.init(config); + return factory.create(null); + } + + static X509Certificate[] getCertificateChain(Credential cred) + throws CertificateException, NoSuchAlgorithmException { + return Arrays.stream(cred.getCertificates()).map(cert -> (X509Certificate) cert) + .toArray(X509Certificate[]::new); + } + + static String getXfccWithCert(Credential cred) + throws CertificateException, NoSuchAlgorithmException, IOException { + return String.format("Hash=1234;Cert=\"%s\"", + URLEncoder.encode(cred.getCertificateAsPem(), StandardCharsets.UTF_8)); + } + + static String getXfccWithChain(Credential cred) + throws CertificateException, NoSuchAlgorithmException, IOException { + return String.format("Hash=1234;Chain=\"%s\"", + URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); + } + + static String getXfccWithCertAndChain(Credential cred) + throws CertificateException, NoSuchAlgorithmException, IOException { + return String.format("Hash=1234;Cert=\"%s\";Chain=\"%s\"", + URLEncoder.encode(cred.getCertificateAsPem(), StandardCharsets.UTF_8), + URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); + } + + static KeyStore newKeyStore(Credential cred) + throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("credential", cred.getPrivateKey(), STORE_PASSWORD.toCharArray(), + new Certificate[] { cred.getCertificate() }); + return ks; + } + + static KeyStore newTrustStore(Credential cred) + throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException { + KeyStore ts = KeyStore.getInstance("PKCS12"); + ts.load(null, null); + ts.setCertificateEntry("credential", cred.getCertificate()); + return ts; + } + +} diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/LoggingExtension.java b/src/test/java/io/github/nordix/keycloak/services/x509/LoggingExtension.java new file mode 100644 index 0000000..cb45d49 --- /dev/null +++ b/src/test/java/io/github/nordix/keycloak/services/x509/LoggingExtension.java @@ -0,0 +1,39 @@ +package io.github.nordix.keycloak.services.x509; + +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestWatcher; + +public class LoggingExtension implements TestWatcher, BeforeTestExecutionCallback { + + private static Logger logger = Logger.getLogger(LoggingExtension.class); + + @Override + public void testDisabled(ExtensionContext context, Optional reason) { + logger.warnv("Test {0} is disabled: {11}", context.getDisplayName(), reason.orElse("No reason provided")); + } + + @Override + public void testSuccessful(ExtensionContext context) { + logger.infov("Test {0} succeeded", context.getDisplayName()); + } + + @Override + public void testAborted(ExtensionContext context, Throwable cause) { + logger.errorv(cause, "Test {0} aborted", context.getDisplayName()); + } + + @Override + public void testFailed(ExtensionContext context, Throwable cause) { + logger.errorv(cause, "Test {0} failed", context.getDisplayName()); + } + + @Override + public void beforeTestExecution(ExtensionContext context) throws Exception { + logger.infov("Starting test {0}", context.getDisplayName()); + } + +} diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java deleted file mode 100644 index 55b960a..0000000 --- a/src/test/java/io/github/nordix/keycloak/services/x509/PathValidationTest.java +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Copyright (c) 2024 OpenInfra Foundation Europe and others. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Apache License, Version 2.0 - * which accompanies this distribution, and is available at - * http://www.apache.org/licenses/LICENSE-2.0 - */ -package io.github.nordix.keycloak.services.x509; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.Arrays; - -import org.jboss.resteasy.mock.MockHttpRequest; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.keycloak.Config.Scope; -import org.keycloak.common.crypto.CryptoIntegration; -import org.keycloak.common.crypto.CryptoProvider; -import org.keycloak.http.HttpRequest; -import org.keycloak.services.x509.X509ClientCertificateLookup; - -import fi.protonode.certy.Credential; - -public class PathValidationTest { - - private static Credential envoy1; - private static Credential envoy2; - private static Credential client1; - private static Credential client2; - - @BeforeAll - public static void createCertificates() throws Exception { - // Initialize the Keycloak default crypto provider. - CryptoIntegration.init(CryptoProvider.class.getClassLoader()); - - // Certs used for Envoy->Keycloak the perimeter of the Envoy proxy, e.g. - // Kubernetes cluster-internal PKI. - Credential internalRootCa = new Credential().subject("CN=internal root CA"); - Credential internalSubCa = new Credential().subject("CN=internal sub CA").ca(true).issuer(internalRootCa); - envoy1 = new Credential().subject("CN=Envoy 1,OU=clients,O=example.com").ca(false).issuer(internalSubCa); - envoy2 = new Credential().subject("CN=Envoy 2,OU=clients,O=example.com").ca(false).issuer(internalSubCa); - - // Following certificfates represent the PKI for external clients. - Credential externalRootCa = new Credential().subject("CN=external root CA"); - Credential externalSubCa = new Credential().subject("CN=external sub CA").ca(true).issuer(externalRootCa); - client1 = new Credential().subject("CN=Client 1,OU=clients,O=example.com").ca(false) - .issuer(externalSubCa); - client2 = new Credential().subject("CN=Client 2,OU=clients,O=example.com").ca(false) - .issuer(externalSubCa); - } - - /** - * Test that the client certificate chain is extracted from XFCC header when - * request is over TLS and client certificate matches with the expected path. - */ - @Test - public void testTlsRequestWithXfccFromAuthorizedProxy() throws Exception { - X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); - X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); - - HttpRequest request = new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", - getXfccValue(client1))) - .setClientCertificateChain(tlsLayerClientCerts); - - X509Certificate[] certs = lookup.getCertificateChain(request); - - // Check that client1 certificate from XFCC is returned. - Assertions.assertNotNull(certs); - Assertions.assertEquals(2, certs.length); - Assertions.assertArrayEquals(client1.getCertificates(), certs); - } - - /** - * Test that the client certificate chain is not extracted from XFCC header when - * request is over TLS and client certificate does not match with the expected - * path. - */ - @Test - public void testTlsRequestWithXfccFromUnauthorizedProxy() throws Exception { - X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"CN=does not match\"]]"); - X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); - - HttpRequest request = new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", - getXfccValue(client1))) - .setClientCertificateChain(tlsLayerClientCerts); - - // Check that envoy1 certificate is returned. - X509Certificate[] certs = lookup.getCertificateChain(request); - Assertions.assertNotNull(certs); - Assertions.assertEquals(2, certs.length); - Assertions.assertArrayEquals(envoy1.getCertificates(), certs); - } - - /** - * Test that the client certificate chain is not extracted from XFCC header when - * the request is not over TLS and the configuration requires certificate path - * validation. - */ - @Test - public void testNonTlsRequestWithXfcc() throws Exception { - X509ClientCertificateLookup lookup = createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); - - HttpRequest request = new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", - getXfccValue(client1))); - - // Check that no certificate is returned. - X509Certificate[] certs = lookup.getCertificateChain(request); - Assertions.assertNull(certs); - } - - /** - * Test that the client certificate chain is extracted from XFCC header when - * request is over TLS and client certificate matches with one of multiple - * expected paths. - */ - @Test - public void testTlsRequestWithXfccMultipleAllowedProxies() throws Exception { - X509ClientCertificateLookup lookup = createLookupWithConfig( - "[[\"O=example.com,OU=clients,CN=Envoy 1\"],[\"O=example.com,OU=clients,CN=Envoy 2\"]]"); - - X509Certificate[] tlsLayerClientCerts = getCertificateChain(envoy1); - - HttpRequest request = new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", - getXfccValue(client1))) - .setClientCertificateChain(tlsLayerClientCerts); - - // Check that client1 certificate from XFCC is returned. - X509Certificate[] certs = lookup.getCertificateChain(request); - Assertions.assertNotNull(certs); - Assertions.assertEquals(2, certs.length); - Assertions.assertArrayEquals(client1.getCertificates(), certs); - - tlsLayerClientCerts = getCertificateChain(envoy2); - - request = new HttpRequestImpl( - MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", - getXfccValue(client2))) - .setClientCertificateChain(tlsLayerClientCerts); - - // Check that client2 certificate from XFCC is returned. - certs = lookup.getCertificateChain(request); - Assertions.assertNotNull(certs); - Assertions.assertEquals(2, certs.length); - Assertions.assertArrayEquals(client2.getCertificates(), certs); - } - - - // Helper methods. - - private static X509ClientCertificateLookup createLookupWithConfig(String configJson) { - Scope config = ScopeImpl.fromPairs("cert-path-verify", configJson); - EnvoyProxySslClientCertificateLookupFactory factory = new EnvoyProxySslClientCertificateLookupFactory(); - factory.init(config); - return factory.create(null); - } - - private static X509Certificate[] getCertificateChain(Credential cred) - throws CertificateException, NoSuchAlgorithmException { - return Arrays.stream(cred.getCertificates()).map(cert -> (X509Certificate) cert) - .toArray(X509Certificate[]::new); - } - - private static String getXfccValue(Credential cred) - throws CertificateException, NoSuchAlgorithmException, IOException { - return String.format("Hash=1234;Cert=\"%s\"", - URLEncoder.encode(cred.getCertificatesAsPem(), StandardCharsets.UTF_8)); - } -} diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/PathVericationTest.java b/src/test/java/io/github/nordix/keycloak/services/x509/PathVericationTest.java new file mode 100644 index 0000000..6926cc9 --- /dev/null +++ b/src/test/java/io/github/nordix/keycloak/services/x509/PathVericationTest.java @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2024 OpenInfra Foundation Europe and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution, and is available at + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.github.nordix.keycloak.services.x509; + +import java.security.cert.X509Certificate; + +import org.jboss.resteasy.mock.MockHttpRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; +import org.keycloak.http.HttpRequest; +import org.keycloak.services.x509.X509ClientCertificateLookup; + +import fi.protonode.certy.Credential; + +/** + * Unit tests for testing authorization of using XFCC, based on the path of the client certificate. + */ +@ExtendWith(LoggingExtension.class) +public class PathVericationTest { + + private static Credential envoy1; + private static Credential envoy2; + private static Credential client1; + private static Credential client2; + + @BeforeAll + public static void createCertificates() { + // Initialize the Keycloak default crypto provider. + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + + // Certs used for Envoy->Keycloak the perimeter of the Envoy proxy, e.g. + // Kubernetes cluster-internal PKI. + Credential internalRootCa = new Credential().subject("CN=internal root CA"); + Credential internalSubCa = new Credential().subject("CN=internal sub CA").ca(true).issuer(internalRootCa); + envoy1 = new Credential().subject("CN=Envoy 1,OU=clients,O=example.com").ca(false).issuer(internalSubCa); + envoy2 = new Credential().subject("CN=Envoy 2,OU=clients,O=example.com").ca(false).issuer(internalSubCa); + + // Following certificfates represent the PKI for external clients. + Credential externalRootCa = new Credential().subject("CN=external root CA"); + Credential externalSubCa = new Credential().subject("CN=external sub CA").ca(true).issuer(externalRootCa); + client1 = new Credential().subject("CN=Client 1,OU=clients,O=example.com").ca(false).issuer(externalSubCa); + client2 = new Credential().subject("CN=Client 2,OU=clients,O=example.com").ca(false).issuer(externalSubCa); + } + + /** + * Test that the client certificate chain is extracted from XFCC header when: + * - Configuration requires client path verification. + * - Full chain verification configured (excluding root CA). + * - TLS level client certificate matches with the expected path. + */ + @Test + void testTlsRequestWithXfccFromAuthorizedProxy() throws Exception { + X509ClientCertificateLookup lookup = Helpers.createLookupWithConfig( + "[[\"O=example.com,OU=clients,CN=Envoy 1\", \"CN=internal sub CA\"]]"); + X509Certificate[] tlsLayerClientCerts = Helpers.getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithCert(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + X509Certificate[] certs = lookup.getCertificateChain(request); + + // Check that client1 certificate from XFCC is returned. + Assertions.assertNotNull(certs); + Assertions.assertEquals(1, certs.length); + Assertions.assertEquals(client1.getX509Certificate(), certs[0]); + } + + /** + * Test that the client certificate chain is extracted from XFCC header when: + * - Configuration requires client path verification. + * - Partial chain configured (only leaf-certificate given). + * - TLS level client certificate matches with the expected path. + */ + @Test + void testTlsRequestWithXfccPartialChainVerification() throws Exception { + X509ClientCertificateLookup lookup = Helpers + .createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); + + X509Certificate[] tlsLayerClientCerts = Helpers.getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithCert(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that client1 certificate from XFCC is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(1, certs.length); + Assertions.assertEquals(client1.getX509Certificate(), certs[0]); + } + + + /** + * Test that the client certificate chain is not extracted from XFCC header when: + * - Configuration requires client path verification. + * - Request has client certificate that does not match with the expected path. + */ + @Test + void testTlsRequestWithXfccFromUnauthorizedProxy() throws Exception { + X509ClientCertificateLookup lookup = Helpers.createLookupWithConfig("[[\"CN=does not match\"]]"); + X509Certificate[] tlsLayerClientCerts = Helpers.getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithCert(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that no certificate is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNull(certs); + } + + /** + * Test that the client certificate chain is not extracted from XFCC header when: + * - Request is not over TLS or client certificate was not sent. + * - Configuration requires client path verification. + */ + @Test + void testNonTlsRequestWithXfcc() throws Exception { + X509ClientCertificateLookup lookup = Helpers + .createLookupWithConfig("[[\"O=example.com,OU=clients,CN=Envoy 1\"]]"); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithCert(client1))); + + // Check that no certificate is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNull(certs); + } + + /** + * Test that the client certificate chain is extracted from XFCC header when: + * - Configuration requires client path verification. + * - Multiple allowed paths are configured. + * - TLS level client certificate matches with one of the expected paths. + */ + @Test + void testTlsRequestWithXfccMultipleAllowedPaths() throws Exception { + X509ClientCertificateLookup lookup = Helpers.createLookupWithConfig( + "[[\"O=example.com,OU=clients,CN=Envoy 1\"],[\"O=example.com,OU=clients,CN=Envoy 2\"]]"); + + X509Certificate[] tlsLayerClientCerts = Helpers.getCertificateChain(envoy1); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithChain(client1))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that client1 certificate from XFCC is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(client1.getCertificates(), certs); + + tlsLayerClientCerts = Helpers.getCertificateChain(envoy2); + + request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar").header("x-forwarded-client-cert", + Helpers.getXfccWithChain(client2))).setClientCertificateChain(tlsLayerClientCerts); + + // Check that client2 certificate from XFCC is returned. + certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertEquals(2, certs.length); + Assertions.assertArrayEquals(client2.getCertificates(), certs); + } + + /** + * Test that XFCC is not processed if configured client certificate verification path is empty. + * This can be used to disable XFCC lookup while the plugin is still enabled. + */ + @Test + void testTlsRequestWithXfccNoVerificationPaths() throws Exception { + X509ClientCertificateLookup lookup = Helpers.createLookupWithConfig("[]"); + + X509Certificate[] tlsLayerClientCerts = Helpers.getCertificateChain(client1); + + HttpRequest request = new HttpRequestImpl(MockHttpRequest.create("GET", "http://foo/bar") + .header("x-forwarded-client-cert", Helpers.getXfccWithChain(client2))) + .setClientCertificateChain(tlsLayerClientCerts); + + // Check that client certificate from TLS layer is returned. + X509Certificate[] certs = lookup.getCertificateChain(request); + Assertions.assertNotNull(certs); + Assertions.assertArrayEquals(client1.getCertificates(), certs); + } +} diff --git a/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java b/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java index e256225..7f7fda3 100644 --- a/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java +++ b/src/test/java/io/github/nordix/keycloak/services/x509/ScopeImpl.java @@ -64,7 +64,8 @@ public Integer getInt(String key) { @Override public Integer getInt(String key, Integer defaultValue) { - throw new UnsupportedOperationException("Unimplemented method 'getInt'"); + String val = properties.get(key); + return val != null ? Integer.parseInt(val) : defaultValue; } @Override diff --git a/src/test/resources/integration-test/envoy-xfcc.yaml b/src/test/resources/integration-test/envoy-xfcc.yaml index 0acdbb5..9aa0d32 100644 --- a/src/test/resources/integration-test/envoy-xfcc.yaml +++ b/src/test/resources/integration-test/envoy-xfcc.yaml @@ -8,27 +8,12 @@ admin: static_resources: clusters: - # Cluster for Keycloak's HTTP port (no TLS between Envoy and Keycloak) - name: keycloak type: STRICT_DNS connect_timeout: 5s load_assignment: cluster_name: keycloak endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: keycloak - port_value: 8080 - - # Cluster for Keycloak's HTTPS port, using client cert for Envoy that is authorized to send XFCC. - - name: keycloak-https-with-authorized-client-cert - type: STRICT_DNS - connect_timeout: 5s - load_assignment: - cluster_name: keycloak-https-with-authorized-client-cert - endpoints: - lb_endpoints: - endpoint: address: @@ -42,9 +27,9 @@ static_resources: common_tls_context: tls_certificates: certificate_chain: - filename: "/input/target/certs/authorized-client.pem" + filename: "/input/target/certs/envoy-client.pem" private_key: - filename: "/input/target/certs/authorized-client-key.pem" + filename: "/input/target/certs/envoy-client-key.pem" validation_context: trusted_ca: filename: "/input/target/certs/server-ca.pem" @@ -74,7 +59,6 @@ static_resources: name: myroute virtual_hosts: - # Virtual host for connecting to Keycloak's HTTP port (no TLS between Envoy and Keycloak) - name: keycloak domains: - "keycloak.127.0.0.1.nip.io:8443" @@ -84,16 +68,6 @@ static_resources: route: cluster: keycloak - # Virtual host for connecting to Keycloak's HTTPS port, using client cert for Envoy that is authorized to send XFCC. - - name: keycloak-https - domains: - - "keycloak-https.127.0.0.1.nip.io:8443" - routes: - - match: - prefix: "/" - route: - cluster: keycloak-https-with-authorized-client-cert - forward_client_cert_details: SANITIZE_SET set_current_client_cert_details: #subject: true diff --git a/src/test/resources/integration-test/keycloak-realm.json b/src/test/resources/integration-test/keycloak-realm.json index 180451c..fbe508d 100644 --- a/src/test/resources/integration-test/keycloak-realm.json +++ b/src/test/resources/integration-test/keycloak-realm.json @@ -13,8 +13,8 @@ "serviceAccountsEnabled": true, "attributes": { "post.logout.redirect.uris": "https://keycloak.127.0.0.1.nip.io:8443/*", - "x509.allow.regex.pattern.comparison": true, - "x509.subjectdn": "(.*?)(?:$)" + "x509.allow.regex.pattern.comparison": false, + "x509.subjectdn": "CN=authorized-client" } } ] diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties index ec99e36..fdf30b8 100644 --- a/src/test/resources/logging.properties +++ b/src/test/resources/logging.properties @@ -9,11 +9,12 @@ logger.io.github.nordix.keycloak.services.x509.level=DEBUG logger.handlers=CONSOLE handler.CONSOLE=org.jboss.logmanager.handlers.ConsoleHandler -handler.CONSOLE.properties=autoFlush +handler.CONSOLE.properties=autoFlush,target handler.CONSOLE.level=DEBUG handler.CONSOLE.autoFlush=true handler.CONSOLE.formatter=PATTERN -formatter.PATTERN=org.jboss.logmanager.formatters.PatternFormatter +formatter.PATTERN=org.jboss.logmanager.formatters.ColorPatternFormatter +#formatter.PATTERN.pattern=%K{level}%d{HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n +formatter.PATTERN.pattern=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n formatter.PATTERN.properties=pattern -formatter.PATTERN.pattern=%d{HH:mm:ss,SSS} %-5p %t [%c] %m%n From 782fa00fc3c103b05e734e785ae6d2c592acb79b Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Thu, 31 Oct 2024 08:53:50 +0200 Subject: [PATCH 10/11] testing long diagram on a page --- docs/assets/client-authorization-flow.drawio.svg | 4 ++++ docs/security-and-client-cert-forwarding.md | 7 +++++++ 2 files changed, 11 insertions(+) create mode 100644 docs/assets/client-authorization-flow.drawio.svg diff --git a/docs/assets/client-authorization-flow.drawio.svg b/docs/assets/client-authorization-flow.drawio.svg new file mode 100644 index 0000000..371cddc --- /dev/null +++ b/docs/assets/client-authorization-flow.drawio.svg @@ -0,0 +1,4 @@ + + + +
X509 Certificate Based Client Authorization Flow
X509 Certificate Based Client Authorization Flow
Client
Client
Send request
Send...
Keycloak
Keycloak
Verify client certificate (optional)
Verify client certificate...
X509 Certificate Client Authenticator
X509 Certificate Client...
INVALID
INVALID
Disconnect
Disconnect
Is request for client with X509 Authenticator configured?
Is request for client wit...
VALID (OR NO CLIENT CERT)
VALID (OR NO CLIENT CERT)
NO
NO
Continue with other client authenticator.
Continue with other client...
Authorize request
Autho...
Envoy X509 client certificate lookup
Envoy X509 client certificate lookup
Is cert-path-verify configured?
Is cert-path-verify confi...
NO
NO
YES
YES
Return client certificate from the XFCC header (if any).
Return client certificate...
Check if the received request includes a client certificate in the TLS layer.
Check if the received requ...
NO
NO
YES
YES
Do not return client certificate.
Do not return client certi...
DOES NOT MATCH
DOES NOT MATCH
Return client certificate from TLS layer.
Return client certificate...
Attempt to verify the TLS layer client certificate path with given criteria.
Attempt to verify the TLS...
MATCHES
MATCHES
Return client certificate from the XFCC header (if any).
Return client certificate...
YES
YES
\ No newline at end of file diff --git a/docs/security-and-client-cert-forwarding.md b/docs/security-and-client-cert-forwarding.md index c4e9f15..3d5298b 100644 --- a/docs/security-and-client-cert-forwarding.md +++ b/docs/security-and-client-cert-forwarding.md @@ -51,3 +51,10 @@ This can include a Keycloak admin client to obtain full access to Keycloak. The forged certificate can be self-generated by the malicious user, as long as it contains the correct subject name. ![image](assets/xfcc-scenario-2.drawio.svg) + + +## TEST + +This is a test + +![image](assets/client-authorization-flow.drawio.svg) From 29724dd0f4468734f86f2551f0b8efcc21a1af33 Mon Sep 17 00:00:00 2001 From: Tero Saarni Date: Thu, 31 Oct 2024 10:24:58 +0200 Subject: [PATCH 11/11] updated documentation for the verification --- README.md | 61 +++++++++++++-------- docs/security-and-client-cert-forwarding.md | 25 +++++---- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index ade093b..2e39c40 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,14 @@ This project provides an X509 client certificate lookup implementation for [Envoy proxy](https://www.envoyproxy.io/). It allows Keycloak to retrieve the client certificate from the `x-forwarded-client-cert` (XFCC) header set by Envoy and use it for authorization. -For more information, refer to [Keycloak's reverse proxy documentation](https://www.keycloak.org/server/reverseproxy) and the section [Enabling client certificate lookup](https://www.keycloak.org/server/reverseproxy#_enabling_client_certificate_lookup). -See also [Envoy's documentation](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert) on XFCC header. -See [Configuring Kubernetes Ingress Controllers for Client Certificate Forwarding](docs/ingress-controllers.md) for more information on how to configure Kubernetes ingress controllers for client certificate forwarding. +This project was created because the code submitted in [keycloak#33159](https://github.com/keycloak/keycloak/pull/33159) was not accepted. +Instead, Keycloak encourages the development of implementations for different proxies as extensions. + > ⚠️ **Alert:** There are implications that you should be aware of when enabling client certificate lookup in Keycloak. For more information, see [Understanding Client Certificate Forwarding and Security Implications](docs/security-and-client-cert-forwarding.md). -This project was created because the code submitted in [keycloak#33159](https://github.com/keycloak/keycloak/pull/33159) was not accepted. -Instead, Keycloak encourages the development of implementations for different proxies as extensions. - ## Installation This project is not available on Maven Central. @@ -26,6 +23,18 @@ Clone the repository and execute: The JAR file will be created in the `target` directory. Copy the JAR file to the `providers` directory in your Keycloak distribution. For instance, in the official Keycloak Docker image releases, place the JAR file in the `/opt/keycloak/providers/`. + +## Configuration + +For information on how to use the project, refer to following documents: + +* See [here](docs/ingress-controllers.md) on how to configure Kubernetes ingress controllers for client certificate forwarding. +* For more information on the Keycloak feature, refer to [Keycloak's reverse proxy documentation](https://www.keycloak.org/server/reverseproxy) and the section [Enabling client certificate lookup](https://www.keycloak.org/server/reverseproxy#_enabling_client_certificate_lookup). +* See also [Envoy's documentation](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert) on XFCC header. + + +### Enable client certificate lookup (mandatory) + Add following command line parameter to `kc.sh` to choose the provider: ``` @@ -45,35 +54,41 @@ This project may require updates for newer Keycloak versions. Refer to Keycloak's [Configuring Providers](https://www.keycloak.org/server/configuration-provider) documentation for more information. -## Configuration +### Authorizing clients that are allowed to send XFCC headers (optional) -### Authorizing clients that are allowed to send XFCC headers +If Keycloak is deployed in an environment where some clients must bypass the proxy, it is important to ensure that only Envoy can send XFCC headers. +This prevents clients from impersonating other users by sending XFCC headers. +For more information on the verification process, refer to [this section](docs/security-and-client-cert-forwarding.md#mitigation) of the security implications document. -If Keycloak is deployed in environment where not all requests are forwarded via the proxy, it is important to ensure that only requests from the proxy are allowed to send XFCC headers. -This is to prevent clients running inside the perimeter of the proxy from impersonating users. -The prerequisite for this is that the proxy uses TLS and client certificate authentication for the connection to Keycloak. -When the TLS connection is established, Keycloak will verify the client certificate, including the the certificate chain against trusted CAs. -After successful verification, the request is sent to Envoy Client certificate lookup SPI, which then uses the certificate chain information to authorize the use of XFCC headers. +Prerequisites: -The authorization is configured by specifying the expected list of X509 subject names in the client certificate chain: +* Envoy must TLS and client certificate authentication for its connection to Keycloak. +* Configure the list of expected client certificate subject names that are allowed to send XFCC headers. + +The list is configured as a command line parameter to `kc.sh` in the following format: ``` ---spi-x509cert-lookup-envoy-cert-path-verify="[ [ , , ... ], ... ]" +--spi-x509cert-lookup-envoy-cert-path-verify="[ [ , , ... ], ... ]" ``` +The presence of this parameter is optional and its behavior is as follows: -The configuration is a JSON array of arrays. -Multiple chains of subject names can be specified in the configuration. -Each inner array represents a certificate chain, where the first element is the subject name of the leaf certificate and the following elements are for the intermediate certificates. -Root certificate is not included in the configuration. +| Parameter value | Description | Example | +| --- | --- | --- | +| Not set | Any client can send XFCC headers, and they will be processed. | N/A | +| Empty array | XFCC headers will not be processed from any client. | `--spi-x509cert-lookup-envoy-cert-path-verify='[]'` | +| Non-empty array | XFCC headers will be processed only if the client certificate chain matches the specified subject names. | `--spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy" ]]'` | -For example, to allow the client certificate chain with the subject name `CN=envoy, O=example.com` and the intermediate certificate with the subject name `CN=intermediate, O=example.com`, use the following configuration: +The parameter value is a JSON array of arrays. +Each inner array represents a certificate chain, with the first element as the leaf certificate's subject name and subsequent elements as intermediate certificates. +Root certificates should not be included. +For example, to allow a client certificate chain with the subject name `CN=envoy, O=example.com` and an intermediate certificate with the subject name `CN=intermediate, O=example.com`, use: ``` ---spi-x509cert-lookup-envoy-cert-path-verify='[["CN=envoy, O=example.com", "CN=intermediate, O=example.com"]]' +--spi-x509cert-lookup-envoy-cert-path-verify='[[ "CN=envoy, O=example.com", "CN=intermediate, O=example.com" ]]' ``` -If the parameter is not set, the client certificate chain is not verified and all requests with XFCC headers are allowed. - +The subject names must match exactly, as X.500 Distinguished Names (DN) are order-sensitive (`CN=envoy, O=example.com` is not the same as `O=example.com, CN=envoy`). +The path can be partial: verification succeeds if the expected subject names are found in order, even if the received chain has additional certificates. ## Development diff --git a/docs/security-and-client-cert-forwarding.md b/docs/security-and-client-cert-forwarding.md index 3d5298b..c81ce2e 100644 --- a/docs/security-and-client-cert-forwarding.md +++ b/docs/security-and-client-cert-forwarding.md @@ -1,11 +1,10 @@ # Understanding Client Certificate Forwarding and Security Implications -This document outlines the security implications and risks of enabling client certificate forwarding in reverse proxies. +This document outlines the security implications, risks of enabling client certificate forwarding in reverse proxies and mitigations. These concerns are not exclusive to Envoy but apply to any reverse proxy that forwards client certificates. The initial architectural assumption is that all requests originate from the proxy, preventing direct client access to Keycloak. However, in environments like Kubernetes, some clients may bypass the proxy, leading to additional considerations. - ## Overview The `x-forwarded-client-cert` (XFCC) header is used by Envoy proxy to send the client certificate information to the backend service, such as Keycloak. @@ -17,16 +16,15 @@ Keycloak then uses the certificate for authorization purposes. ![image](assets/xfcc-intro.drawio.svg) - ## Scenarios ### Scenario 1: Failed Authentication of an Client Running Inside the Proxy's Perimeter Pre-conditions: -* Keycloak is configured with the X509 client certificate lookup SPI for Envoy proxy. -* A client is created to Keycloak with "X509 client certificate" client authenticator enabled. -* A client running inside Kubernetes cluster connects to Keycloak directly, without going through the Envoy proxy, using mutually authenticated TLS. +- Keycloak is configured with the X509 client certificate lookup SPI for Envoy proxy. +- A client is created to Keycloak with "X509 client certificate" client authenticator enabled. +- A client running inside Kubernetes cluster connects to Keycloak directly, without going through the Envoy proxy, using mutually authenticated TLS. Scenario: @@ -39,10 +37,10 @@ The client certificate information from the TLS layer is not used. Pre-conditions: -* Keycloak is configured with the X509 client certificate lookup SPI for Envoy proxy. -* A client is created to Keycloak with "X509 client certificate" client authenticator enabled. -* A malicious user has acquired (1) the client ID and (2) the subject name of the client certificate. -* Malicious user has gained access to the cluster e.g., through a compromised pod. +- Keycloak is configured with the X509 client certificate lookup SPI for Envoy proxy. +- A client is created to Keycloak with "X509 client certificate" client authenticator enabled. +- A malicious user has acquired (1) the client ID and (2) the subject name of the client certificate. +- Malicious user has gained access to the cluster e.g., through a compromised pod. Scenario: @@ -52,9 +50,12 @@ The forged certificate can be self-generated by the malicious user, as long as i ![image](assets/xfcc-scenario-2.drawio.svg) +## Mitigation -## TEST +The following diagram demonstrates the logic implemented by the [X509 client certificate lookup SPI for Envoy proxy](https://github.com/Nordix/keycloak-client-cert-lookup-for-envoy). +This mechanism is designed to accept client certificates forwarded by the Envoy proxy while also securely handling clients that bypass the proxy and connect directly to Keycloak. +These direct clients are authenticated using their TLS-level client certificates or other authenticators (e.g. client secret) and are prevented from impersonating other clients by sending XFCC headers. -This is a test +For this mitigation to be effective, Envoy must be configured to use TLS and client certificate authentication for its connection to Keycloak. Additionally, the X509 client certificate lookup SPI must be configured with the expected client certificate subject names for Envoy proxy. ![image](assets/client-authorization-flow.drawio.svg)