Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Security via operator, OIDC trusted certificates #1314

Merged
merged 14 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
MikeEdgar marked this conversation as resolved.
Show resolved Hide resolved
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());
MikeEdgar marked this conversation as resolved.
Show resolved Hide resolved
} 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}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The operator overrides this to use an emptydir volume in Kube, maintaining a read-only root container filesystem.

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")
MikeEdgar marked this conversation as resolved.
Show resolved Hide resolved
.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