diff --git a/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java b/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java index 0ee850d86..130a6e305 100644 --- a/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java +++ b/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java @@ -2,7 +2,9 @@ import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; import java.io.OutputStream; +import java.io.UncheckedIOException; import java.security.KeyStore; import java.util.List; import java.util.Optional; @@ -37,7 +39,7 @@ public class OidcTenantConfigResolver implements TenantConfigResolver { Logger logger; @Inject - @ConfigProperty(name = "console.work-path", defaultValue = "${java.io.tmpdir}") + @ConfigProperty(name = "console.work-path") String workPath; @Inject @@ -75,16 +77,19 @@ Optional getTlsConfiguration() { } void configureTruststore(KeyStore truststore) { - String filename = "%s%s%s-truststore.%s".formatted( - workPath, - File.separator, - UUID.randomUUID().toString(), - truststore.getType() - ); - File file = new File(filename); + File workDir = new File(workPath); + File truststoreFile; + + try { + truststoreFile = File.createTempFile("oidc-provider-trust", "." + truststore.getType(), workDir); + truststoreFile.deleteOnExit(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + String secret = UUID.randomUUID().toString(); - try (OutputStream out = new FileOutputStream(file)) { + try (OutputStream out = new FileOutputStream(truststoreFile)) { truststore.store(out, secret.toCharArray()); } catch (Exception e) { throw new RuntimeException(e); @@ -92,7 +97,7 @@ void configureTruststore(KeyStore truststore) { // No default provided, set to empty to avoid NPE oidcConfig.tls.trustStoreProvider = Optional.empty(); - oidcConfig.tls.setTrustStoreFile(file.toPath()); + oidcConfig.tls.setTrustStoreFile(truststoreFile.toPath()); oidcConfig.tls.setTrustStorePassword(secret); // Future: map the certificate alias if provided // oidcConfig.tls.setTrustStoreCertAlias(null); diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 5d4494bc9..7c5aff231 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -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 diff --git a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java index 407797fc9..db7d3e252 100644 --- a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java +++ b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java @@ -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; @@ -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 { @@ -29,26 +33,56 @@ public Map 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()); + //truststoreFile.deleteOnExit(); + Files.write(truststoreFile.toPath(), tls.getTrustStoreBytes()); + } 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 diff --git a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java index 9af0a021f..3dadd4044 100644 --- a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java +++ b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java @@ -4,10 +4,14 @@ 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; @@ -15,14 +19,26 @@ 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) { @@ -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(); diff --git a/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java b/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java new file mode 100644 index 000000000..857d7a06e --- /dev/null +++ b/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java @@ -0,0 +1,226 @@ +package com.github.streamshub.console.test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; + +public class TlsHelper { + + private static final String BC_PROVIDER = "BC"; + private static final String KEY_ALGORITHM = "RSA"; + private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + private static final String PKCS12 = "PKCS12"; + + static { + // Add the BouncyCastle Provider + Security.addProvider(new BouncyCastleProvider()); + } + + public static TlsHelper newInstance() { + try { + return new TlsHelper(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Certificate rootCA; + private KeyStore keyStore; + private KeyStore trustStore; + private final char[] passphrase = UUID.randomUUID().toString().toCharArray(); + + public Certificate getRootCA() { + return rootCA; + } + + public String getRootCAPem() { + try { + return pemEncodeCertificate(rootCA); + } catch (CertificateEncodingException | IOException e) { + throw new RuntimeException(e); + } + } + + public KeyStore getKeyStore() { + return keyStore; + } + + public byte[] getKeyStoreBytes() { + return getBytes(keyStore, passphrase); + } + + public KeyStore getTrustStore() { + return trustStore; + } + + public byte[] getTrustStoreBytes() { + return getBytes(trustStore, passphrase); + } + + public char[] getPassphrase() { + return passphrase; + } + + private byte[] getBytes(KeyStore store, char[] passphrase) { + try { + return toByteArray(store, passphrase); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + } + + private TlsHelper() throws Exception { + // Initialize a new KeyPair generator + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM, BC_PROVIDER); + keyPairGenerator.initialize(2048); + + LocalDateTime now = LocalDateTime.now(); + Date notBefore = Date.from(now.minusDays(1).toInstant(ZoneOffset.UTC)); + Date notAfter = Date.from(now.plusYears(1).toInstant(ZoneOffset.UTC)); + + KeyPair rootKeyPair = keyPairGenerator.generateKeyPair(); + X509Certificate rootCert = buildCACertificate(rootKeyPair, notBefore, notAfter); + + KeyPair issuedCertKeyPair = keyPairGenerator.generateKeyPair(); + Certificate issuedCert = buildServerCertificate(issuedCertKeyPair, rootKeyPair, rootCert, notBefore, notAfter); + + rootCA = rootCert; + + trustStore = KeyStore.getInstance("JKS"); + trustStore.load(null, passphrase); + trustStore.setCertificateEntry("CACert", rootCert); + + keyStore = KeyStore.getInstance(PKCS12, BC_PROVIDER); + keyStore.load(null, passphrase); + keyStore.setKeyEntry("localhost", issuedCertKeyPair.getPrivate(), null, new Certificate[] { + issuedCert, + rootCert + }); + keyStore.setCertificateEntry("CACert", rootCert); + } + + private X509Certificate buildCACertificate(KeyPair keyPair, Date notBefore, Date notAfter) + throws OperatorCreationException, IOException, GeneralSecurityException { + + BigInteger rootSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + // Issued By and Issued To same for root certificate + X500Name rootCertIssuer = new X500Name("CN=root-cert"); + X500Name rootCertSubject = rootCertIssuer; + ContentSigner rootCertContentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER) + .build(keyPair.getPrivate()); + X509v3CertificateBuilder rootCertBuilder = new JcaX509v3CertificateBuilder(rootCertIssuer, rootSerialNum, + notBefore, notAfter, rootCertSubject, keyPair.getPublic()); + + // Add Extensions + // A BasicConstraint to mark root certificate as CA certificate + JcaX509ExtensionUtils rootCertExtUtils = new JcaX509ExtensionUtils(); + rootCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + rootCertBuilder.addExtension(Extension.subjectKeyIdentifier, false, + rootCertExtUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // Create a cert holder and export to X509Certificate + X509CertificateHolder rootCertHolder = rootCertBuilder.build(rootCertContentSigner); + return new JcaX509CertificateConverter().setProvider(BC_PROVIDER) + .getCertificate(rootCertHolder); + } + + private Certificate buildServerCertificate(KeyPair keyPair, KeyPair signerKeyPair, X509Certificate signerCert, Date notBefore, Date notAfter) + throws GeneralSecurityException, IOException, OperatorCreationException { + + // Generate a new KeyPair and sign it using the Root Cert Private Key + // by generating a CSR (Certificate Signing Request) + X500Name issuedCertSubject = new X500Name("CN=localhost,O=com.github.streamshub"); + BigInteger issuedCertSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(issuedCertSubject, + keyPair.getPublic()); + JcaContentSignerBuilder csrBuilder = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER); + + // Sign the new KeyPair with the root cert Private Key + ContentSigner csrContentSigner = csrBuilder.build(signerKeyPair.getPrivate()); + PKCS10CertificationRequest csr = p10Builder.build(csrContentSigner); + + // Use the Signed KeyPair and CSR to generate an issued Certificate + // Here serial number is randomly generated. In general, CAs use + // a sequence to generate Serial number and avoid collisions + var issuer = new X500Name(signerCert.getSubjectX500Principal().getName()); + X509v3CertificateBuilder issuedCertBuilder = new X509v3CertificateBuilder(issuer, issuedCertSerialNum, + notBefore, notAfter, csr.getSubject(), csr.getSubjectPublicKeyInfo()); + + JcaX509ExtensionUtils issuedCertExtUtils = new JcaX509ExtensionUtils(); + + // Add Extensions + // Use BasicConstraints to say that this Cert is not a CA + issuedCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + // Add Issuer cert identifier as Extension + issuedCertBuilder.addExtension(Extension.authorityKeyIdentifier, false, issuedCertExtUtils.createAuthorityKeyIdentifier(signerCert)); + + // Add intended key usage extension if needed + issuedCertBuilder.addExtension(Extension.keyUsage, false, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + + // Add DNS name is cert is to used for SSL + GeneralNames subjectAltName = new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost") + }); + issuedCertBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName); + + X509CertificateHolder issuedCertHolder = issuedCertBuilder.build(csrContentSigner); + X509Certificate issuedCert = new JcaX509CertificateConverter().setProvider(BC_PROVIDER) + .getCertificate(issuedCertHolder); + + // Verify the issued cert signature against the root (issuer) cert + issuedCert.verify(signerCert.getPublicKey(), BC_PROVIDER); + return issuedCert; + } + + private byte[] toByteArray(KeyStore store, char[] passphrase) throws GeneralSecurityException, IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + store.store(buffer, passphrase); + return buffer.toByteArray(); + } + + private String pemEncodeCertificate(Certificate certificate) throws IOException, CertificateEncodingException { + ByteArrayOutputStream certificateOut = new ByteArrayOutputStream(); + certificateOut.write("-----BEGIN CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + certificateOut.write(Base64.getMimeEncoder(80, new byte[] {'\n'}).encode(certificate.getEncoded())); + certificateOut.write("\n-----END CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + certificateOut.close(); + return new String(certificateOut.toByteArray(), StandardCharsets.UTF_8); + } +}