Skip to content

Commit

Permalink
Security via operator, OIDC trusted certificates (#1314)
Browse files Browse the repository at this point in the history
* Update operator for security configuration
* Validate configuration earlier in reconciliation, set conditions
* add security testing, minor re-factoring
* fix Sonar issues
* Configure truststore for OIDC provider
* Exclude auth for non-API paths, fix status update, fix dep status chk
* map UI variable for PEM truststore
* always pull images without SHAs
* fix: default replica values in deployment status to zero
* Add test for OIDC truststore w/JKS to PEM conversion
* Remove dead code, additional testing for invalid CR scenario
* Use TLS + truststore with Keycloak for API OIDC tests
* Use predefined set of unauthenticated paths instead of non-`/api` paths
* Use secure file attributes for temp truststore, add JavaDoc comment
* Resolve issues from review feedback and Sonar scanning

Signed-off-by: Michael Edgar <[email protected]>
  • Loading branch information
MikeEdgar authored Jan 9, 2025
1 parent 08bd404 commit 49e2ad1
Show file tree
Hide file tree
Showing 37 changed files with 2,772 additions and 916 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ public class ConsoleAuthenticationMechanism implements HttpAuthenticationMechani
.setPrincipal(new QuarkusPrincipal("ANONYMOUS"))
.build();

private static final Set<String> UNAUTHENTICATED_PATHS = Set.of("/health", "/metrics", "/openapi", "/swagger-ui");

@Inject
Logger log;

Expand All @@ -102,6 +104,12 @@ boolean oidcEnabled() {

@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
final String requestPath = context.normalizedPath();

if (UNAUTHENTICATED_PATHS.stream().anyMatch(requestPath::startsWith)) {
return Uni.createFrom().nullItem();
}

if (oidcEnabled()) {
return oidc.authenticate(context, identityProviderManager)
.map(identity -> augmentIdentity(context, identity))
Expand Down Expand Up @@ -171,7 +179,13 @@ public Uni<ChallengeData> getChallenge(RoutingContext context) {
var category = ErrorCategory.get(ErrorCategory.NotAuthenticated.class);
Error error = category.createError("Authentication credentials missing or invalid", null, null);
var responseBody = new ErrorResponse(List.of(error));
return new PayloadChallengeData(data, responseBody);
return (ChallengeData) new PayloadChallengeData(data, responseBody);
})
.onFailure().recoverWithItem(t -> {
var category = ErrorCategory.get(ErrorCategory.ServerError.class);
Error error = category.createError("Authentication failed due to internal server error", null, null);
var responseBody = new ErrorResponse(List.of(error));
return new PayloadChallengeData(500, null, null, responseBody);
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
package com.github.streamshub.console.api.security;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyStore;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import com.github.streamshub.console.config.ConsoleConfig;

import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.tls.TlsConfiguration;
import io.quarkus.tls.TlsConfigurationRegistry;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

Expand All @@ -22,6 +38,16 @@
@ApplicationScoped
public class OidcTenantConfigResolver implements TenantConfigResolver {

@Inject
Logger logger;

@Inject
@ConfigProperty(name = "console.work-path")
String workPath;

@Inject
TlsConfigurationRegistry tlsRegistry;

@Inject
ConsoleConfig consoleConfig;

Expand All @@ -40,6 +66,56 @@ void initialize() {
if (oidc.getIssuer() != null) {
oidcConfig.getToken().setIssuer(oidc.getIssuer());
}

getTlsConfiguration().map(TlsConfiguration::getTrustStore).ifPresentOrElse(
this::configureTruststore,
() -> logger.infof("No truststore configured for OIDC provider")
);
}

Optional<TlsConfiguration> getTlsConfiguration() {
String dotSeparatedSource = "oidc.provider.trust";
String dashSeparatedSource = "oidc-provider-trust";
return tlsRegistry.get(dotSeparatedSource).or(() -> tlsRegistry.get(dashSeparatedSource));
}

/**
* The OIDC subsystem takes the path to a truststore, so we need to write the
* one from the TLS registry to a working file to provide to OIDC. This should
* no longer be necessary in the next Quarkus LTS where OIDC is aware of the TLS
* registry.
*/
void configureTruststore(KeyStore truststore) {
File workDir = new File(workPath);
Path truststorePath;
File truststoreFile;

try {
truststorePath = Files.createTempFile(
workDir.toPath(),
"oidc-provider-trust",
"." + truststore.getType(),
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
truststoreFile = truststorePath.toFile();
truststoreFile.deleteOnExit();
} catch (IOException e) {
throw new UncheckedIOException(e);
}

String secret = UUID.randomUUID().toString();

try (OutputStream out = new FileOutputStream(truststoreFile)) {
truststore.store(out, secret.toCharArray());
} catch (Exception e) {
throw new RuntimeException(e);
}

// No default provided, set to empty to avoid NPE
oidcConfig.tls.trustStoreProvider = Optional.empty();
oidcConfig.tls.setTrustStoreFile(truststorePath);
oidcConfig.tls.setTrustStorePassword(secret);
// Future: map the certificate alias if provided
// oidcConfig.tls.setTrustStoreCertAlias(null);
}

@Override
Expand Down
1 change: 1 addition & 0 deletions api/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ quarkus.arc.exclude-types=io.apicurio.registry.rest.JacksonDateTimeCustomizer
quarkus.index-dependency.strimzi-api.group-id=io.strimzi
quarkus.index-dependency.strimzi-api.artifact-id=api

console.work-path=${java.io.tmpdir}
console.kafka.admin.request.timeout.ms=10000
console.kafka.admin.default.api.timeout.ms=10000

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.github.streamshub.console.kafka.systemtest.deployment;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Map;

Expand All @@ -12,6 +14,8 @@
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.Transferable;

import com.github.streamshub.console.test.TlsHelper;

import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;

public class KeycloakResourceManager implements QuarkusTestResourceLifecycleManager {
Expand All @@ -29,26 +33,56 @@ public Map<String, String> start() {
throw new UncheckedIOException(ioe);
}

int port = 8443;
TlsHelper tls = TlsHelper.newInstance();
String keystorePath = "/opt/keycloak/keystore.p12";

keycloak = new GenericContainer<>("quay.io/keycloak/keycloak:26.0")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("systemtests.keycloak"), true))
.withExposedPorts(8080)
.withExposedPorts(port)
.withEnv(Map.of(
"KC_BOOTSTRAP_ADMIN_USERNAME", "admin",
"KC_BOOTSTRAP_ADMIN_PASSWORD", "admin",
"PROXY_ADDRESS_FORWARDING", "true"))
.withCopyToContainer(
Transferable.of(tls.getKeyStoreBytes()),
keystorePath)
.withCopyToContainer(
Transferable.of(realmConfig),
"/opt/keycloak/data/import/console-realm.json")
.withCommand("start", "--hostname=localhost", "--http-enabled=true", "--import-realm")
.waitingFor(Wait.forHttp("/realms/console-authz").withStartupTimeout(Duration.ofMinutes(1)));
.withCommand(
"start",
"--hostname=localhost",
"--http-enabled=false",
"--https-key-store-file=%s".formatted(keystorePath),
"--https-key-store-password=%s".formatted(String.copyValueOf(tls.getPassphrase())),
"--import-realm"
)
.waitingFor(Wait.forHttps("/realms/console-authz")
.allowInsecure()
.withStartupTimeout(Duration.ofMinutes(1)));

File truststoreFile;

try {
truststoreFile = File.createTempFile("oidc-provider-trust", "." + tls.getTrustStore().getType());
Files.write(truststoreFile.toPath(), tls.getTrustStoreBytes());
truststoreFile.deleteOnExit();
} catch (IOException e) {
throw new UncheckedIOException(e);
}

keycloak.start();

String urlTemplate = "http://localhost:%d/realms/console-authz";
var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(8080));
String urlTemplate = "https://localhost:%d/realms/console-authz";
var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(port));
return Map.of(
"console.test.oidc-url", oidcUrl,
"console.test.oidc-issuer", urlTemplate.formatted(8080));
"console.test.oidc-host", "localhost:%d".formatted(port),
"console.test.oidc-issuer", urlTemplate.formatted(port),
"quarkus.tls.\"oidc-provider-trust\".trust-store.jks.path", truststoreFile.getAbsolutePath(),
"quarkus.tls.\"oidc-provider-trust\".trust-store.jks.password", String.copyValueOf(tls.getPassphrase())
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,41 @@
import java.io.StringReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

import javax.net.ssl.SSLContext;

import jakarta.enterprise.inject.spi.CDI;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.JsonReader;
import jakarta.ws.rs.core.HttpHeaders;

import org.eclipse.microprofile.config.Config;

import io.quarkus.tls.TlsConfigurationRegistry;
import io.restassured.http.Header;

public class TokenUtils {

final String tokenEndpoint;
final String tokenEndpointHost;
final SSLContext tls;

public TokenUtils(Config config) {
this.tokenEndpoint = config.getValue("console.test.oidc-url", String.class) + "/protocol/openid-connect/token";
this.tokenEndpointHost = config.getValue("console.test.oidc-host", String.class);

var tlsRegistry = CDI.current().select(TlsConfigurationRegistry.class).get();

try {
tls = tlsRegistry.get("oidc-provider-trust").get().createSSLContext();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public Header authorizationHeader(String username) {
Expand All @@ -47,11 +63,14 @@ public JsonObject getTokenObject(String username) {
+ "password=%1$s-password&"
+ "client_id=console-client", username);

HttpClient client = HttpClient.newBuilder().build();
HttpClient client = HttpClient.newBuilder()
.sslContext(tls)
.version(Version.HTTP_1_1)
.build();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenEndpoint))
.header("Host", "localhost:8080")
.header("Host", tokenEndpointHost)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
Expand Down
Loading

0 comments on commit 49e2ad1

Please sign in to comment.