Skip to content

Commit

Permalink
Add XFCC request authorization support
Browse files Browse the repository at this point in the history
Signed-off-by: Tero Saarni <[email protected]>
  • Loading branch information
tsaarni committed Oct 25, 2024
1 parent e446f77 commit 307b5b1
Show file tree
Hide file tree
Showing 9 changed files with 436 additions and 10 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="[ [ <leaf-cert>, <intermediate-cert>, ... ], ... ]"
```

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.
Expand Down
6 changes: 4 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<java.util.logging.config.file>
${project.basedir}/src/test/resources/logging.properties</java.util.logging.config.file>
</systemPropertyVariables>
</configuration>
</plugin>
Expand All @@ -141,7 +143,7 @@
</pluginManagement>

<plugins>
<!-- Integration test configuration -->
<!-- Integration test -->
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven.failsafe.plugin.version}</version>
Expand All @@ -165,7 +167,7 @@
</executions>
</plugin>

<!-- Checkstyle configuration -->
<!-- Checkstyle -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
// <leaf certificate subject, intermediate certificate subject, ...>
private List<List<X500Principal>> 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<List<X500Principal>> validCertPaths) {
this.validCertPaths = validCertPaths;
}

@Override
public void close() {
}
Expand Down Expand Up @@ -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(",");
Expand Down Expand Up @@ -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<X500Principal> path = new ArrayList<>();
for (X509Certificate cert : clientCerts) {
path.add(cert.getSubjectX500Principal());
}

logger.debugv("Client certificate chain path: {0}", path);

for (List<X500Principal> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,56 @@
*/
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.
*/
public class EnvoyProxySslClientCertificateLookupFactory implements X509ClientCertificateLookupFactory {

private final static String PROVIDER = "envoy";

private List<List<X500Principal>> 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<List<List<X500Principal>>>() {});
} 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
Expand All @@ -42,4 +72,11 @@ public void close() {
public String getId() {
return PROVIDER;
}

public class X500PrincipalDeserializer extends JsonDeserializer<X500Principal> {
@Override
public X500Principal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
return new X500Principal(p.getValueAsString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,12 +51,16 @@ public MultivaluedMap<String, FormPartValue> getMultiPartFormParameters() {

@Override
public X509Certificate[] getClientCertificateChain() {
throw new UnsupportedOperationException("Unimplemented method 'getClientCertificateChain'");
return clientCertificateChain;
}

@Override
public UriInfo getUri() {
throw new UnsupportedOperationException("Unimplemented method 'getUri'");
}

public HttpRequestImpl setClientCertificateChain(X509Certificate[] clientCertificateChain) {
this.clientCertificateChain = clientCertificateChain;
return this;
}
}
Loading

0 comments on commit 307b5b1

Please sign in to comment.