From 8780b50552f1eac5210d249062a914b85c7006ca Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 12 Jun 2026 20:39:01 +0200 Subject: [PATCH 01/10] fix: skip unreadable kubeconfig CA paths during TLS setup Stale or missing certificate-authority file paths in kubeconfig must not break TLS trust resolution on a different machine. Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .../devtools/gateway/auth/tls/KubeConfigTlsUtils.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt index 8d31a407..bf5b1bb0 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -34,10 +34,14 @@ object KubeConfigTlsUtils { namedCluster: KubeConfigNamedCluster ): List { val caSource = namedCluster.cluster.certificateAuthority ?: return emptyList() - val caContent = if (caSource.isFilePath) { - caSource.toPath().readText() - } else { - Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + val caContent = try { + if (caSource.isFilePath) { + caSource.toPath().readText() + } else { + Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + } + } catch (_: Exception) { + return emptyList() } val factory = CertificateFactory.getInstance("X.509") From 84c781f6741a7ba569caa00852431118eb0dc5ea Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 12 Jun 2026 20:39:56 +0200 Subject: [PATCH 02/10] fix: build token API client from wizard TLS context Use the TLS trust established in the wizard for post-login API connections instead of kubeconfig CA settings or JVM default trust. Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .../auth/code/OpenShiftAuthCodeFlow.kt | 32 +++++++ .../auth/tls/DefaultTlsTrustManager.kt | 84 ++++++++++++++--- .../gateway/auth/tls/KubeConfigTlsUtils.kt | 2 +- .../gateway/auth/tls/SessionTlsTrustStore.kt | 4 + .../auth/tls/ui/TLSTrustDecisionHandler.kt | 60 ++++++++++--- ...sionAdapter.kt => UITlsDecisionAdapter.kt} | 20 +++-- .../openshift/OpenShiftClientFactory.kt | 79 +++++++++------- .../redhat/devtools/gateway/util/UrlUtils.kt | 8 ++ .../view/steps/DevSpacesServerStepView.kt | 42 ++++++--- .../auth/AbstractAuthenticationStrategy.kt | 89 +++++++++++++------ ...ClientCertificateAuthenticationStrategy.kt | 10 +-- ...nShiftCredentialsAuthenticationStrategy.kt | 17 +--- .../OpenShiftOAuthAuthenticationStrategy.kt | 56 ++++++------ .../auth/RedHatSSOAuthenticationStrategy.kt | 8 +- .../steps/auth/TokenAuthenticationStrategy.kt | 7 +- 15 files changed, 354 insertions(+), 164 deletions(-) rename src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/{UiTlsDecisionAdapter.kt => UITlsDecisionAdapter.kt} (66%) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index 8293d4c2..42b37084 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -18,6 +18,7 @@ import com.nimbusds.oauth2.sdk.id.State import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce +import com.redhat.devtools.gateway.util.toServerBaseUrl import kotlinx.coroutines.* import kotlinx.coroutines.future.await import kotlinx.serialization.SerialName @@ -77,6 +78,37 @@ class OpenShiftAuthCodeFlow( val tokenEndpoint: String ) + companion object { + private val discoveryJson = Json { ignoreUnknownKeys = true } + + /** OAuth HTTP endpoint base URLs discovered from the API server. */ + suspend fun discoverOAuthEndpointBaseUrls( + apiServerUrl: String, + sslContext: SSLContext, + ): List { + val client = HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + val request = HttpRequest.newBuilder() + .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) + .GET() + .build() + + val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") + } + + val metadata = discoveryJson.decodeFromString(OAuthMetadata.serializer(), response.body()) + return listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) + .map { URI(it).toServerBaseUrl() } + .distinct() + } + } + /** * Discover OAuth endpoints from the cluster. */ diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 6b0a632b..cc13aaa7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -11,7 +11,9 @@ */ package com.redhat.devtools.gateway.auth.tls +import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import com.redhat.devtools.gateway.util.toServerBaseUrl import io.kubernetes.client.util.KubeConfig import kotlinx.coroutines.* import java.net.URI @@ -42,21 +44,25 @@ class DefaultTlsTrustManager( return SslContextFactory.insecure() } + val sessionCerts = sessionTrustStore.allCertificates() val trustedCerts = mutableListOf() - namedCluster?.let { - trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) - } - - trustedCerts += sessionTrustStore.get(serverUrl) - val keyStore = persistentKeyStore.loadOrCreate() - val persistentAlias = "host:${serverUri.host}" + // Stale kubeconfig or persistent trust must not override session trust from this wizard. + if (sessionCerts.isEmpty()) { + namedCluster?.let { + trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } - val persistentCert = keyStore.getCertificate(persistentAlias) - if (persistentCert is X509Certificate) { - trustedCerts += persistentCert + val keyStore = persistentKeyStore.loadOrCreate() + val persistentCert = keyStore.getCertificate("host:${serverUri.host}") + if (persistentCert is X509Certificate) { + trustedCerts += persistentCert + } } + trustedCerts += sessionTrustStore.get(serverUrl) + trustedCerts += sessionCerts + if (trustedCerts.isNotEmpty()) { try { val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) @@ -102,6 +108,9 @@ class DefaultTlsTrustManager( throw TlsTrustRejectedException() } + val keyStore = persistentKeyStore.loadOrCreate() + val persistentAlias = "host:${serverUri.host}" + when (decision.scope) { TlsTrustScope.SESSION_ONLY -> { sessionTrustStore.put(serverUrl, listOf(trustAnchor)) @@ -131,7 +140,60 @@ class DefaultTlsTrustManager( } } - /** Private helper: SHA-256 fingerprint of a certificate */ + /** + * Resolves TLS trust for the API server and OAuth endpoints discovered from it. + */ + suspend fun ensureOpenShiftTlsContext( + apiServerUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + ): TlsContext { + val apiBaseUrl = URI(apiServerUrl).toServerBaseUrl() + + ensureTrusted(apiBaseUrl, decisionHandler) + + val apiTls = mergedContextFor(listOf(apiBaseUrl)) + val oauthUrls = runCatching { + OpenShiftAuthCodeFlow.discoverOAuthEndpointBaseUrls( + apiBaseUrl, + apiTls.sslContext, + ) + }.getOrDefault(emptyList()) + + val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() + for (url in allUrls) { + if (url != apiBaseUrl) { + ensureTrusted(url, decisionHandler) + } + } + + return mergedContextFor(allUrls) + } + + suspend fun mergedContextFor(serverUrls: Collection): TlsContext { + val configs = kubeConfigProvider() + val keyStore = persistentKeyStore.loadOrCreate() + val allCerts = mutableListOf() + val sessionCerts = sessionTrustStore.allCertificates() + + for (serverUrl in serverUrls.distinct()) { + val uri = URI(serverUrl) + if (sessionCerts.isEmpty()) { + KubeConfigTlsUtils.findClusterByServer(serverUrl, configs)?.let { + allCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } + val persistentCert = keyStore.getCertificate("host:${uri.host}") + if (persistentCert is X509Certificate) { + allCerts += persistentCert + } + } + allCerts += sessionTrustStore.get(serverUrl) + allCerts += sessionCerts + } + + require(allCerts.isNotEmpty()) { "No trusted certificates for: $serverUrls" } + return SslContextFactory.fromTrustedCerts(allCerts.distinctBy { it.serialNumber }) + } + private fun sha256Fingerprint(cert: X509Certificate): String { val digest = java.security.MessageDigest.getInstance("SHA-256") .digest(cert.encoded) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt index bf5b1bb0..e1741037 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Red Hat, Inc. + * Copyright (c) 2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt index 18a1e4c9..511b8686 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt @@ -24,4 +24,8 @@ class SessionTlsTrustStore { fun put(serverUrl: String, certificates: List) { trusted[serverUrl] = certificates } + + /** All certificates accepted in this wizard session, across every server URL. */ + fun allCertificates(): List = + trusted.values.flatten().distinctBy { it.serialNumber } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt index c59a0e6f..f08a56bd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt @@ -12,14 +12,24 @@ package com.redhat.devtools.gateway.auth.tls.ui import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.VerticalFlowLayout +import com.intellij.ui.HyperlinkLabel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Dimension +import java.awt.FlowLayout import java.awt.event.ActionEvent import javax.swing.Action +import javax.swing.BorderFactory import javax.swing.JComponent import javax.swing.JPanel -import javax.swing.JScrollPane /** * Dialog that asks the user to trust a TLS certificate from a server. @@ -32,6 +42,10 @@ class TLSTrustDecisionHandler( private val certificateInfo: String ) : DialogWrapper(true) { + companion object { + val PREFERRED_SIZE = Dimension(500, 400) + } + /** Will be true if user chose to persist the trust decision. */ var rememberDecision: Boolean = false private set @@ -48,23 +62,45 @@ class TLSTrustDecisionHandler( override fun createCenterPanel(): JComponent { val panel = JPanel(BorderLayout(8, 8)) - val message = JBTextArea( - "The server at $serverUrl presents a TLS certificate that is not trusted.\n" + - "You can choose to trust it permanently, trust it for this session only, or cancel the connection." - ) - message.isEditable = false - message.isOpaque = false - message.lineWrap = true - message.wrapStyleWord = true + val message = panel { + row { + cell( + JPanel(VerticalFlowLayout( + VerticalFlowLayout.LEFT, + 0, + JBUI.scale(4), + true, + false + )).apply { + isOpaque = false + add(JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + isOpaque = false + add(JBLabel("The server at ")) + add(HyperlinkLabel(serverUrl).apply { setHyperlinkTarget(serverUrl) }) + add(JBTextArea("presents a TLS certificate that is not trusted.")) + } + ) + add( + JBTextArea( + "You can choose to trust it permanently, trust it for this session only, or cancel the connection." + ) + ) + } + ).align(AlignX.FILL) + }.topGap(TopGap.MEDIUM).bottomGap(BottomGap.MEDIUM) + } val certArea = JBTextArea(certificateInfo).apply { isEditable = false lineWrap = false - font = message.font + //font = JBLabel().font + border = BorderFactory.createEmptyBorder() } - val scrollPane = JScrollPane(certArea).apply { - preferredSize = Dimension(600, 200) + val scrollPane = JBScrollPane(certArea).apply { + preferredSize = PREFERRED_SIZE + setBorder(JBUI.Borders.empty()) + setViewportBorder(null) } panel.add(message, BorderLayout.NORTH) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt similarity index 66% rename from src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt rename to src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt index 0298bd01..9b3b8bfd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -12,20 +12,24 @@ package com.redhat.devtools.gateway.auth.tls.ui import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.redhat.devtools.gateway.auth.tls.* -object UiTlsDecisionAdapter { +object UITlsDecisionAdapter { suspend fun decide(info: TlsServerCertificateInfo): TlsTrustDecision { lateinit var dialog: TLSTrustDecisionHandler - ApplicationManager.getApplication().invokeAndWait { - dialog = TLSTrustDecisionHandler( - serverUrl = info.serverUrl, - certificateInfo = PemUtils.toPem(info.certificateChain.first()) - ) - dialog.show() - } + ApplicationManager.getApplication().invokeAndWait( + { + dialog = TLSTrustDecisionHandler( + serverUrl = info.serverUrl, + certificateInfo = PemUtils.toPem(info.certificateChain.first()) + ) + dialog.show() + }, + ModalityState.any(), + ) return when { !dialog.isTrusted -> diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt index e1e0092c..27e1f291 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -20,6 +20,9 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.Config import io.kubernetes.client.util.KubeConfig +import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import okhttp3.OkHttpClient +import okhttp3.Protocol import java.io.IOException import kotlin.io.path.readText import java.security.KeyStore @@ -28,7 +31,6 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { @@ -38,6 +40,10 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { private var lastUsedKubeConfig: KubeConfig? = null + companion object { + private const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L + } + class Builder internal constructor( private val factory: OpenShiftClientFactory, private val server: String, @@ -117,25 +123,51 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { val kubeConfig = createKubeConfig(server, certificateAuthority, token, clientCert, clientKey) lastUsedKubeConfig = kubeConfig - val client = Config.fromConfig(kubeConfig) - val trustManager: X509TrustManager = createTrustManager(certificateAuthority, tlsContext) - val sslContext = createSSLContext(trustManager, usingClientCert, clientCert, clientKey) - client.httpClient = client.httpClient.newBuilder() - .sslSocketFactory(sslContext.socketFactory, trustManager) - .build() + return if (usingClientCert) { + createWithClientCert(kubeConfig, clientCert, clientKey, tlsContext) + } else { + createWithToken(server, token!!, tlsContext) + } + } + /** + * Builds a token-authenticated client using the same [TlsContext] SSL stack as OAuth. + * Avoids [Config.fromConfig], which applies JVM default trust via [ApiClient.applySslSettings]. + */ + private fun createWithToken(server: String, token: CharArray, tlsContext: TlsContext): ApiClient { + val client = ApiClient(buildHttpClient(tlsContext.sslContext, tlsContext.trustManager)) + client.basePath = normalizeBasePath(server) + AccessTokenAuthentication(String(token).trim()).provide(client) return client } - private fun createTrustManager( - certificateAuthority: CertificateSource?, + private fun createWithClientCert( + kubeConfig: KubeConfig, + clientCert: CertificateSource, + clientKey: CertificateSource, tlsContext: TlsContext - ): X509TrustManager = if (certificateAuthority != null) { - createTrustManager(certificateAuthority) - } else { - tlsContext.trustManager + ): ApiClient { + val trustManager = tlsContext.trustManager + val sslContext = createSSLContext(trustManager, true, clientCert, clientKey) + val client = Config.fromConfig(kubeConfig) + client.httpClient = buildHttpClient(sslContext, trustManager) + return client } + private fun buildHttpClient(sslContext: SSLContext, trustManager: X509TrustManager): OkHttpClient { + return OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .callTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + // Match OAuth HttpClient (HTTP/1.1); some clusters hang on HTTP/2. + .protocols(listOf(Protocol.HTTP_1_1)) + .build() + } + + private fun normalizeBasePath(server: String): String = server.trim().removeSuffix("/") + private fun createSSLContext( trustManager: X509TrustManager, usingClientCert: Boolean, @@ -158,27 +190,6 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { } } - private fun createTrustManager( - caSource: CertificateSource - ): X509TrustManager { - - val caContent = resolve(caSource) - val caCert = PemUtils.parseCertificate(caContent) - - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(null, null) - keyStore.setCertificateEntry("ca", caCert) - - val tmf = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm() - ) - tmf.init(keyStore) - - return tmf.trustManagers - .filterIsInstance() - .first() - } - private fun createKeyManagers( certSource: CertificateSource, keySource: CertificateSource diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt index 03e4dbd1..8b36e876 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt @@ -11,4 +11,12 @@ */ package com.redhat.devtools.gateway.util +import java.net.URI + fun String.stripScheme(): String = substringAfter("://", this) + +/** Returns the scheme/host/port base URL used for TLS trust lookups. */ +fun URI.toServerBaseUrl(): String { + val port = port + return if (port > 0) "$scheme://$host:$port" else "$scheme://$host" +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 8397f9e3..5d2e9ae4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.PathManager import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.ui.MessageDialogBuilder @@ -31,7 +32,7 @@ import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.* -import com.redhat.devtools.gateway.auth.tls.ui.UiTlsDecisionAdapter +import com.redhat.devtools.gateway.auth.tls.ui.UITlsDecisionAdapter import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate @@ -116,8 +117,8 @@ class DevSpacesServerStepView( ::createEnterKeyListener ) - val setTokenDisplay: suspend (String) -> Unit = { token -> - withContext(Dispatchers.Main) { + val setTokenDisplay: (String) -> Unit = { token -> + ApplicationManager.getApplication().invokeLater { tokenStrategy.tfToken.text = token } } @@ -127,7 +128,7 @@ class DevSpacesServerStepView( OpenShiftOAuthAuthenticationStrategy( tfServer, ::saveKubeconfig, - setTokenDisplay + setTokenDisplay, ), ClientCertificateAuthenticationStrategy( tfServer, @@ -140,7 +141,7 @@ class DevSpacesServerStepView( ::saveKubeconfig, ::onFieldChanged, ::createEnterKeyListener, - setTokenDisplay + setTokenDisplay, ), RedHatSSOAuthenticationStrategy( tfServer, @@ -395,10 +396,12 @@ class DevSpacesServerStepView( { runBlocking { val indicator = ProgressManager.getInstance().progressIndicator - indicator.text = "Connecting to cluster..." try { - val tlsContext = resolveSslContext(server) + indicator.text = "Establishing secure connection..." + val tlsContext = resolveTlsContext(server, strategy.getAuthMethod()) + + indicator.text = "Connecting to cluster..." val certAuthorityData = tfCertAuthority.text.ifBlank { null } strategy.authenticate( @@ -410,6 +413,8 @@ class DevSpacesServerStepView( indicator ) authResult = Result.success(Unit) + } catch (e: ProcessCanceledException) { + throw e } catch (e: Exception) { authResult = Result.failure(e) } @@ -504,10 +509,23 @@ class DevSpacesServerStepView( persistentKeyStore = persistentKeyStore ) + private suspend fun resolveTlsContext(serverUrl: String, authMethod: AuthMethod): TlsContext { + return when (authMethod) { + AuthMethod.OPENSHIFT, AuthMethod.OPENSHIFT_CREDENTIALS -> + tlsTrustManager.ensureOpenShiftTlsContext( + apiServerUrl = serverUrl, + decisionHandler = UITlsDecisionAdapter::decide, + ) + + else -> + resolveSslContext(serverUrl) + } + } + private suspend fun resolveSslContext(serverUrl: String): TlsContext { return tlsTrustManager.ensureTrusted( serverUrl = serverUrl, - decisionHandler = UiTlsDecisionAdapter::decide + decisionHandler = UITlsDecisionAdapter::decide ) } @@ -516,7 +534,7 @@ class DevSpacesServerStepView( try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { KubeConfigUpdate .create( cluster.name.trim(), @@ -527,7 +545,7 @@ class DevSpacesServerStepView( } catch (e: Exception) { thisLogger().warn(e.message ?: "Could not save configuration file", e) - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { Dialogs.error(e.message ?: "Could not save configuration file", "Save Config Failed") } } @@ -541,7 +559,7 @@ class DevSpacesServerStepView( try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { KubeConfigUpdate .create( cluster.name.trim(), @@ -552,7 +570,7 @@ class DevSpacesServerStepView( } } catch (e: Exception) { thisLogger().warn(e.message ?: "Could not save configuration file", e) - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { Dialogs.error(e.message ?: "Could not save configuration file", "Save Config Failed") } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt index 48b7fa65..8c0e3047 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -25,8 +25,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds /** @@ -65,40 +68,74 @@ abstract class AbstractAuthenticationStrategy( } /** - * Creates a validated API client. + * Builds a token-authenticated API client using the wizard TLS context. + * Runs synchronously so it is safe inside [ProgressManager.runProcessWithProgressSynchronously]. + */ + protected fun createTokenApiClient( + server: String, + token: String, + tlsContext: TlsContext, + ): ApiClient = + OpenShiftClientFactory(KubeConfigUtils).create( + server, + certificateAuthority = null, + token = token.toCharArray(), + clientCert = null, + clientKey = null, + tlsContext = tlsContext, + ) + + /** + * Creates a validated API client on a worker thread. + * Cluster TLS trust comes from [tlsContext] (established earlier in the wizard), not from + * kubeconfig certificate-authority paths that may be stale on this machine. */ @Throws(AuthenticationException::class) - protected fun createValidatedApiClient( + protected suspend fun createValidatedApiClient( server: String, - certAuthority: String? = null, token: String? = null, clientCert: String? = null, clientKey: String? = null, tlsContext: TlsContext, + probeApiAccess: Boolean = true, errorMessage: String? = null - ): ApiClient = try { - val caSource = CertificateSource.fromPathOrPem(certAuthority) - caSource?.validate() - val certSource = CertificateSource.fromPathOrPem(clientCert) - certSource?.validate() - val keySource = CertificateSource.fromPathOrPem(clientKey) - keySource?.validate() + ): ApiClient = withContext(Dispatchers.IO) { + coroutineContext.ensureActive() + try { + val certSource = resolveRequiredCertificateSource(clientCert) + val keySource = resolveRequiredCertificateSource(clientKey) - OpenShiftClientFactory(KubeConfigUtils) - .create( - server, - caSource, - token?.toCharArray(), - certSource, - keySource, - tlsContext - ) - .also { client -> - require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } - } - } catch (e: ApiException) { - throw AuthenticationException(e.codeToReasonPhrase(), e) - } catch (e: Exception) { - throw AuthenticationException(e.message ?: "Authentication failed", e) + OpenShiftClientFactory(KubeConfigUtils) + .create( + server, + certificateAuthority = null, + token?.toCharArray(), + certSource, + keySource, + tlsContext + ) + .also { client -> + if (probeApiAccess) { + coroutineContext.ensureActive() + val authenticated = runInterruptible { + Projects(client).isAuthenticated() + } + require(authenticated) { errorMessage ?: "Not authenticated" } + } + } + } catch (e: ApiException) { + throw AuthenticationException(e.codeToReasonPhrase(), e) + } catch (e: Exception) { + throw AuthenticationException(e.message ?: "Authentication failed", e) + } + } + + /** + * Resolves client certificate/key input. Missing files fail authentication. + */ + private fun resolveRequiredCertificateSource(input: String?): CertificateSource? { + val source = CertificateSource.fromPathOrPem(input) ?: return null + source.validate() + return source } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt index 450589d2..a5e7b7e7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -88,12 +88,10 @@ class ClientCertificateAuthenticationStrategy( val client = createValidatedApiClient( server, - certAuthority, - null, - clientCert, - clientKey, - tlsContext, - "Authentication failed: invalid client certificate or key." + clientCert = clientCert, + clientKey = clientKey, + tlsContext = tlsContext, + errorMessage = "Authentication failed: invalid client certificate or key." ) saveKubeconfigWithCert(selectedCluster, clientCert, clientKey, indicator) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt index 0228c517..e06ddb55 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -40,7 +40,7 @@ class OpenShiftCredentialsAuthenticationStrategy( saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener, - private val setTokenDisplay: suspend (String) -> Unit + private val setTokenDisplay: (String) -> Unit, ) : AbstractAuthenticationStrategy( tfServer, saveKubeconfig @@ -120,7 +120,7 @@ class OpenShiftCredentialsAuthenticationStrategy( apiServerUrl = selectedCluster.url, username = username, password = password, - tlsContext.sslContext + tlsContext.sslContext, ) val finalToken = TokenModel( @@ -131,17 +131,8 @@ class OpenShiftCredentialsAuthenticationStrategy( clusterApiUrl = selectedCluster.url ) - indicator.text = "Validating cluster access..." - - val client = createValidatedApiClient( - server, - certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: invalid OpenShift credentials." - ) + indicator.text = "Finishing connection..." + val client = createTokenApiClient(server, finalToken.accessToken, tlsContext) setTokenDisplay(finalToken.accessToken) saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt index eeab88a4..1e79909d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.dsl.builder.panel import com.redhat.devtools.gateway.DevSpacesBundle @@ -31,7 +32,7 @@ import javax.swing.JPanel class OpenShiftOAuthAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, - private val setTokenDisplay: suspend (String) -> Unit + private val setTokenDisplay: (String) -> Unit, ) : AbstractAuthenticationStrategy( tfServer, saveKubeconfig @@ -56,48 +57,41 @@ class OpenShiftOAuthAuthenticationStrategy( devSpacesContext: DevSpacesContext, indicator: ProgressIndicator ) { - indicator.text = "Authenticating with OpenShift..." - val sessionManager = OpenShiftAuthSessionManager() val login = sessionManager.startBrowserLogin( selectedCluster.url, - tlsContext.sslContext + tlsContext.sslContext, ) - withContext(Dispatchers.Main) { + + ApplicationManager.getApplication().invokeLater { BrowserUtil.browse(login.authorizationUri) } indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() - coroutineScope { - launchCancelWatcher(indicator) { login.cancel() } - - indicator.text = "Obtaining OpenShift access..." - val osToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - - val finalToken = TokenModel( - accessToken = osToken.accessToken, - expiresAt = osToken.expiresAt, - accountLabel = osToken.accountLabel, - kind = AuthTokenKind.TOKEN, - clusterApiUrl = selectedCluster.url - ) + supervisorScope { + val cancelJob = launchCancelWatcher(indicator) { login.cancel() } + try { + indicator.text = "Obtaining OpenShift access..." + val osToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - indicator.text = "Validating cluster access..." - val client = createValidatedApiClient( - server, - certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: token received from OpenShift Authenticator is invalid or expired." - ) + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) - setTokenDisplay(finalToken.accessToken) - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) - devSpacesContext.client = client + indicator.text = "Finishing connection..." + val client = createTokenApiClient(server, finalToken.accessToken, tlsContext) + devSpacesContext.client = client + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } finally { + cancelJob.cancel() + } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt index 94a91dbf..c8382014 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -79,12 +79,10 @@ class RedHatSSOAuthenticationStrategy( try { val client = createValidatedApiClient( - server, certAuthority, + server, finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + tlsContext = tlsContext, + errorMessage = "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." ) // Do not save SSO tokens diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt index 68fc6980..4cceb6b6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt @@ -96,12 +96,9 @@ class TokenAuthenticationStrategy( val client = createValidatedApiClient( server, - certAuthority, token, - null, - null, - tlsContext, - "Authentication failed: invalid server URL or token." + tlsContext = tlsContext, + errorMessage = "Authentication failed: invalid server URL or token." ) saveKubeconfig.invoke(selectedCluster, token, indicator) From 97725581b338e24f7bfbd6d57c6270aba5d11eb3 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 16 Jun 2026 19:18:52 +0200 Subject: [PATCH 03/10] refactor: client factory -> builder Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .gitignore | 3 + io/kubernetes/client/openapi/ApiClient.class | Bin 0 -> 39244 bytes .../gateway/DevSpacesConnectionProvider.kt | 14 +- .../sandbox/SandboxClusterAuthProvider.kt | 10 +- .../openshift/OpenShiftClientBuilder.kt | 253 +++++++++++++ .../openshift/OpenShiftClientFactory.kt | 332 ------------------ .../openshift/OpenShiftKubeConfigUtils.kt | 118 +++++++ .../auth/AbstractAuthenticationStrategy.kt | 28 +- .../openshift/OpenShiftClientBuilderTest.kt | 206 +++++++++++ terminal-to-che.sh | 5 + 10 files changed, 604 insertions(+), 365 deletions(-) create mode 100644 io/kubernetes/client/openapi/ApiClient.class create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt create mode 100755 terminal-to-che.sh diff --git a/.gitignore b/.gitignore index be3b5f6a..45b23f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ build .DS_Store .intellijPlatform/ .kotlin/ +.vscode/ +.opencode/ +.serena/ diff --git a/io/kubernetes/client/openapi/ApiClient.class b/io/kubernetes/client/openapi/ApiClient.class new file mode 100644 index 0000000000000000000000000000000000000000..1a6fd3664156fa149cc1e8d7f299db7e826d587d GIT binary patch literal 39244 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 z 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 literal 0 HcmV?d00001 diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 013b93e8..194d53ea 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -20,9 +20,9 @@ import com.intellij.ui.dsl.builder.panel import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.devworkspace.DevWorkspaces -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.openshift.DefaultClientBuilder import com.redhat.devtools.gateway.openshift.isNotFound import com.redhat.devtools.gateway.openshift.isUnauthorized import com.redhat.devtools.gateway.util.ProgressCountdown @@ -31,12 +31,7 @@ import com.redhat.devtools.gateway.util.messageWithoutPrefix import com.redhat.devtools.gateway.view.SelectClusterDialog import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import java.util.concurrent.CancellationException import javax.swing.JComponent import javax.swing.Timer @@ -205,8 +200,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { val ctx = DevSpacesContext() indicator.update(message = "Initializing Kubernetes connection…") - val factory = OpenShiftClientFactory(KubeConfigUtils) - ctx.client = factory.create() + ctx.client = DefaultClientBuilder(KubeConfigUtils).build() indicator.update(message = "Fetching workspace “$dwName” from namespace “$dwNamespace”…") ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt index 012e9af6..8983443d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt @@ -14,9 +14,7 @@ package com.redhat.devtools.gateway.auth.sandbox import com.redhat.devtools.gateway.auth.code.AuthTokenKind import com.redhat.devtools.gateway.auth.code.SSOToken import com.redhat.devtools.gateway.auth.code.TokenModel -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory -import io.kubernetes.client.openapi.ApiClient +import com.redhat.devtools.gateway.openshift.TokenClientBuilder import io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.models.V1ObjectMeta import io.kubernetes.client.openapi.models.V1Secret @@ -29,8 +27,7 @@ class SandboxClusterAuthProvider( private val sandboxApi: SandboxApi = SandboxApi( SandboxDefaults.SANDBOX_API_BASE_URL, SandboxDefaults.SANDBOX_API_TIMEOUT_MS - ), - private val clientFactory: OpenShiftClientFactory = OpenShiftClientFactory(KubeConfigUtils) + ) ) { suspend fun authenticate(ssoToken: SSOToken): TokenModel { val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) @@ -41,8 +38,7 @@ class SandboxClusterAuthProvider( val username = signup.compliantUsername ?: signup.username val namespace = "$username-dev" - val client = clientFactory - .builder(signup.proxyUrl!!, ssoToken.idToken) + val client = TokenClientBuilder(signup.proxyUrl!!, ssoToken.idToken) .readTimeout(30, TimeUnit.SECONDS) .build() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt new file mode 100644 index 00000000..3f4b47e0 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.util.Config +import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import okhttp3.OkHttpClient +import okhttp3.Protocol +import java.io.IOException +import java.security.KeyStore +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import kotlin.io.path.readText + +/** + * Interface for building OpenShift API clients. + */ +interface OpenShiftClientBuilder { + fun build(): ApiClient + fun readTimeout(timeout: Long, unit: TimeUnit): OpenShiftClientBuilder +} + +/** + * Base class for building OpenShift API clients. + * Provides shared read timeout handling via the [applyReadTimeout] helper. + */ +abstract class BaseClientBuilder : OpenShiftClientBuilder { + private var readTimeoutSeconds: Long = 0 + + override fun readTimeout(timeout: Long, unit: TimeUnit): OpenShiftClientBuilder { + this.readTimeoutSeconds = unit.toSeconds(timeout) + return this + } + + protected fun applyReadTimeout(client: ApiClient): ApiClient { + if (readTimeoutSeconds > 0) { + client.httpClient = client.httpClient.newBuilder() + .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) + .build() + } + return client + } +} + +/** + * Builder for default API clients (no server/token specified). + * Reads kubeconfig files and creates an ApiClient from them. + */ +class DefaultClientBuilder( + private val configUtils: KubeConfigUtils +) : BaseClientBuilder() { + override fun build(): ApiClient { + val paths = configUtils.getAllConfigFiles() + if (paths.isEmpty()) { + thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.") + return ClientBuilder.defaultClient() + } + + return try { + val allConfigs = configUtils.getAllConfigs(paths) + if (allConfigs.isEmpty()) { + thisLogger().debug("No valid kubeconfig content found. Falling back to default ApiClient.") + return ClientBuilder.defaultClient() + } + + val kubeConfig = configUtils.mergeConfigs(allConfigs) + val client = ClientBuilder.kubeconfig(kubeConfig).build() + applyReadTimeout(client) + } catch (e: Exception) { + thisLogger().debug( + "Failed to build effective Kube config from discovered files due to error: ${e.message}. " + + "Falling back to the default ApiClient." + ) + ClientBuilder.defaultClient() + } + } +} + +/** + * Builder for token-authenticated API clients. + * Creates a kubeconfig from the provided server and token, then builds an ApiClient. + */ +class TokenClientBuilder( + private val server: String, + private val token: String +) : BaseClientBuilder() { + override fun build(): ApiClient { + val kubeConfig = createKubeConfig(server, null, token.toCharArray(), null, null) + val client = Config.fromConfig(kubeConfig) + return applyReadTimeout(client) + } +} + +/** + * Builder for TLS-authenticated API clients. + * Handles both token-based and client-certificate authentication with TlsContext. + */ +class TlsClientBuilder( + private val server: String, + private val token: String? = null, + private val clientCert: CertificateSource? = null, + private val clientKey: CertificateSource? = null, + private val tlsContext: TlsContext +) : BaseClientBuilder() { + override fun build(): ApiClient { + validateAuthInputs() + return if (clientCert != null && clientKey != null) { + createWithClientCertFromTls(server, clientCert, clientKey, tlsContext) + } else { + createWithTokenFromTls(server, token!!, tlsContext) + } + } + + private fun validateAuthInputs() { + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + } + + /** + * Builds a client-certificate-authenticated client using the same [TlsContext] SSL stack as OAuth. + */ + internal fun createWithClientCertFromTls( + server: String, + clientCert: CertificateSource, + clientKey: CertificateSource, + tlsContext: TlsContext + ): ApiClient { + val trustManager = tlsContext.trustManager + val sslContext = createSSLContext(trustManager, true, clientCert, clientKey) + val client = ApiClient(createHttpClient(sslContext, trustManager)) + client.basePath = normalizeBasePath(server) + return applyReadTimeout(client) + } + + private fun createSSLContext( + trustManager: X509TrustManager, + usingClientCert: Boolean, + clientCert: CertificateSource?, + clientKey: CertificateSource? + ): SSLContext { + val keyManagers: Array? = + if (usingClientCert && clientCert != null && clientKey != null) { + createKeyManagers(clientCert, clientKey) + } else { + null + } + + return SSLContext.getInstance("TLS").apply { + init( + keyManagers, + arrayOf(trustManager), + SecureRandom() + ) + } + } + + private fun createKeyManagers( + certSource: CertificateSource, + keySource: CertificateSource + ): Array { + + val certContent = resolve(certSource) + val keyContent = resolve(keySource) + + val certificate = PemUtils.parseCertificate(certContent) + val privateKey = PemUtils.parsePrivateKey(keyContent) + + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null) + + keyStore.setKeyEntry( + "client", + privateKey, + CharArray(0), + arrayOf(certificate) + ) + + val kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ) + kmf.init(keyStore, CharArray(0)) + + return kmf.keyManagers + } + + /** + * Resolves CertificateSource to actual content. + * If it's a file path, reads the file. Otherwise returns the value. + */ + private fun resolve(source: CertificateSource): String { + return if (source.isFilePath) { + try { + source.toPath().readText() + } catch (e: Exception) { + throw IOException("Failed to read certificate file: ${source.value}", e) + } + } else { + source.value + } + } + + private fun createHttpClient(sslContext: SSLContext, trustManager: X509TrustManager): OkHttpClient { + return OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .callTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + // Match OAuth HttpClient (HTTP/1.1); some clusters hang on HTTP/2. + .protocols(listOf(Protocol.HTTP_1_1)) + .build() + } + + /** + * Builds a token-authenticated client using the same [TlsContext] SSL stack as OAuth. + * Avoids [io.kubernetes.client.util.Config.fromConfig], which applies JVM default trust via [ApiClient.applySslSettings]. + */ + internal fun createWithTokenFromTls(server: String, token: String, tlsContext: TlsContext): ApiClient { + val client = ApiClient(createHttpClient(tlsContext.sslContext, tlsContext.trustManager)) + client.basePath = normalizeBasePath(server) + AccessTokenAuthentication(token.trim()).provide(client) + return applyReadTimeout(client) + } +} + +private const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L + +private fun normalizeBasePath(server: String): String = server.trim().removeSuffix("/") + + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt deleted file mode 100644 index 27e1f291..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright (c) 2024-2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.openshift - -import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.auth.tls.CertificateSource -import com.redhat.devtools.gateway.auth.tls.PemUtils -import com.redhat.devtools.gateway.auth.tls.TlsContext -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils -import io.kubernetes.client.openapi.ApiClient -import io.kubernetes.client.util.ClientBuilder -import io.kubernetes.client.util.Config -import io.kubernetes.client.util.KubeConfig -import io.kubernetes.client.util.credentials.AccessTokenAuthentication -import okhttp3.OkHttpClient -import okhttp3.Protocol -import java.io.IOException -import kotlin.io.path.readText -import java.security.KeyStore -import java.security.SecureRandom -import java.util.concurrent.TimeUnit -import javax.net.ssl.KeyManager -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SSLContext -import javax.net.ssl.X509TrustManager - -class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { - private val userName = "openshift_user" - private val contextName = "openshift_context" - private val clusterName = "openshift_cluster" - - private var lastUsedKubeConfig: KubeConfig? = null - - companion object { - private const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L - } - - class Builder internal constructor( - private val factory: OpenShiftClientFactory, - private val server: String, - private val token: String - ) { - private var readTimeoutSeconds: Long = 0 - - fun readTimeout(timeout: Long, unit: TimeUnit): Builder { - this.readTimeoutSeconds = unit.toSeconds(timeout) - return this - } - - fun build(): ApiClient { - val client = factory.create(server, token) - if (readTimeoutSeconds > 0) { - client.httpClient = client.httpClient.newBuilder() - .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) - .build() - } - return client - } - } - - fun builder(server: String, token: String): Builder { - return Builder(this, server, token) - } - - fun create(): ApiClient { - val paths = configUtils.getAllConfigFiles() - if (paths.isEmpty()) { - thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.") - lastUsedKubeConfig = null - return ClientBuilder.defaultClient() - } - - return try { - val allConfigs = configUtils.getAllConfigs(paths) - if (allConfigs.isEmpty()) { - thisLogger().debug("No valid kubeconfig content found. Falling back to default ApiClient.") - lastUsedKubeConfig = null - return ClientBuilder.defaultClient() - } - - val kubeConfig = configUtils.mergeConfigs(allConfigs) - lastUsedKubeConfig = kubeConfig - ClientBuilder.kubeconfig(kubeConfig).build() - } catch (e: Exception) { - thisLogger().debug("Failed to build effective Kube config from discovered files due to error: ${e.message}. Falling back to the default ApiClient.") - lastUsedKubeConfig = null - ClientBuilder.defaultClient() - } - } - - fun create(server: String, token: String): ApiClient { - val kubeConfig = createKubeConfig(server, null, token.toCharArray(), null, null) - lastUsedKubeConfig = kubeConfig - return Config.fromConfig(kubeConfig) - } - - fun create( - server: String, - certificateAuthority: CertificateSource? = null, - token: CharArray? = null, - clientCert: CertificateSource? = null, - clientKey: CertificateSource? = null, - tlsContext: TlsContext - ): ApiClient { - - val usingToken = token?.isNotEmpty() == true - val usingClientCert = clientCert != null - && clientKey != null - - require(usingToken.xor(usingClientCert)) { - "Provide either token OR clientCert + clientKey." - } - - val kubeConfig = createKubeConfig(server, certificateAuthority, token, clientCert, clientKey) - lastUsedKubeConfig = kubeConfig - - return if (usingClientCert) { - createWithClientCert(kubeConfig, clientCert, clientKey, tlsContext) - } else { - createWithToken(server, token!!, tlsContext) - } - } - - /** - * Builds a token-authenticated client using the same [TlsContext] SSL stack as OAuth. - * Avoids [Config.fromConfig], which applies JVM default trust via [ApiClient.applySslSettings]. - */ - private fun createWithToken(server: String, token: CharArray, tlsContext: TlsContext): ApiClient { - val client = ApiClient(buildHttpClient(tlsContext.sslContext, tlsContext.trustManager)) - client.basePath = normalizeBasePath(server) - AccessTokenAuthentication(String(token).trim()).provide(client) - return client - } - - private fun createWithClientCert( - kubeConfig: KubeConfig, - clientCert: CertificateSource, - clientKey: CertificateSource, - tlsContext: TlsContext - ): ApiClient { - val trustManager = tlsContext.trustManager - val sslContext = createSSLContext(trustManager, true, clientCert, clientKey) - val client = Config.fromConfig(kubeConfig) - client.httpClient = buildHttpClient(sslContext, trustManager) - return client - } - - private fun buildHttpClient(sslContext: SSLContext, trustManager: X509TrustManager): OkHttpClient { - return OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, trustManager) - .connectTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .writeTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) - // Match OAuth HttpClient (HTTP/1.1); some clusters hang on HTTP/2. - .protocols(listOf(Protocol.HTTP_1_1)) - .build() - } - - private fun normalizeBasePath(server: String): String = server.trim().removeSuffix("/") - - private fun createSSLContext( - trustManager: X509TrustManager, - usingClientCert: Boolean, - clientCert: CertificateSource?, - clientKey: CertificateSource? - ): SSLContext { - val keyManagers: Array? = - if (usingClientCert && clientCert != null && clientKey != null) { - createKeyManagers(clientCert, clientKey) - } else { - null - } - - return SSLContext.getInstance("TLS").apply { - init( - keyManagers, - arrayOf(trustManager), - SecureRandom() - ) - } - } - - private fun createKeyManagers( - certSource: CertificateSource, - keySource: CertificateSource - ): Array { - - val certContent = resolve(certSource) - val keyContent = resolve(keySource) - - val certificate = PemUtils.parseCertificate(certContent) - val privateKey = PemUtils.parsePrivateKey(keyContent) - - val keyStore = KeyStore.getInstance("PKCS12") - keyStore.load(null) - - keyStore.setKeyEntry( - "client", - privateKey, - CharArray(0), - arrayOf(certificate) - ) - - val kmf = KeyManagerFactory.getInstance( - KeyManagerFactory.getDefaultAlgorithm() - ) - kmf.init(keyStore, CharArray(0)) - - return kmf.keyManagers - } - - /** - * Resolves CertificateSource to actual content. - * If it's a file path, reads the file. Otherwise returns the value. - */ - private fun resolve(source: CertificateSource): String { - return if (source.isFilePath) { - try { - source.toPath().readText() - } catch (e: Exception) { - throw IOException("Failed to read certificate file: ${source.value}", e) - } - } else { - source.value - } - } - - private fun createKubeConfig( - server: String, - certificateAuthority: CertificateSource? = null, - token: CharArray? = null, - clientCert: CertificateSource? = null, - clientKey: CertificateSource? = null - ): KubeConfig { - - val usingToken = token?.isNotEmpty() == true - val usingClientCert = clientCert != null && clientKey != null - - require(usingToken.xor(usingClientCert)) { - "Provide either token OR clientCert + clientKey." - } - - val clusterEntry = createCluster(server, certificateAuthority) - val userEntry = createUser(usingToken, token, clientCert, clientKey) - val contextEntry = mapOf( - "name" to contextName, - "context" to mapOf( - "cluster" to clusterName, - "user" to userName - ) - ) - - val kubeConfig = KubeConfig(arrayListOf(contextEntry), arrayListOf(clusterEntry), arrayListOf(userEntry)) - kubeConfig.setContext(contextName) - - return kubeConfig - } - - private fun createCluster( - server: String, - certificateAuthority: CertificateSource? - ): Map { - val cluster = mutableMapOf( - "server" to server.trim() - ) - - certificateAuthority?.let { ca -> - if (ca.isFilePath) { - cluster["certificate-authority"] = ca.value.trim() - } else { - cluster["certificate-authority-data"] = PemUtils.toBase64(ca.value.trim()) - } - } - - val clusterEntry = mapOf( - "name" to clusterName, - "cluster" to cluster - ) - return clusterEntry - } - - private fun createUser(usingToken: Boolean, token: CharArray?, clientCert: CertificateSource?, clientKey: CertificateSource?): Map { - val user = mutableMapOf() - - if (usingToken - && token != null) { - setToken(token, user) - } else { - setClientCertificates(clientCert, clientKey, user) - } - - return mapOf( - "name" to userName, - "user" to user - ) - } - - private fun setToken(token: CharArray, user: MutableMap) { - user["token"] = String(token).trim() - } - - private fun setClientCertificates( - clientCert: CertificateSource?, - clientKey: CertificateSource?, - user: MutableMap - ) { - clientCert?.let { cert -> - if (cert.isFilePath) { - user["client-certificate"] = cert.value.trim() - } else { - user["client-certificate-data"] = PemUtils.toBase64(cert.value.trim()) - } - } - clientKey?.let { key -> - if (key.isFilePath) { - user["client-key"] = key.value.trim() - } else { - user["client-key-data"] = PemUtils.toBase64(key.value.trim()) - } - } - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt new file mode 100644 index 00000000..8ce0322f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import io.kubernetes.client.util.KubeConfig + +private val userName = "openshift_user" +private val contextName = "openshift_context" +private val clusterName = "openshift_cluster" + +/** + * Creates a kubeconfig from the provided parameters. + */ +internal fun createKubeConfig( + server: String, + certificateAuthority: CertificateSource? = null, + token: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null +): KubeConfig { + + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null + + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + + val clusterEntry = createCluster(server, certificateAuthority) + val userEntry = createUser(usingToken, token, clientCert, clientKey) + val contextEntry = mapOf( + "name" to contextName, + "context" to mapOf( + "cluster" to clusterName, + "user" to userName + ) + ) + + val kubeConfig = KubeConfig(arrayListOf(contextEntry), arrayListOf(clusterEntry), arrayListOf(userEntry)) + kubeConfig.setContext(contextName) + + return kubeConfig +} + +private fun createCluster( + server: String, + certificateAuthority: CertificateSource? +): Map { + val cluster = mutableMapOf( + "server" to server.trim() + ) + + certificateAuthority?.let { ca -> + if (ca.isFilePath) { + cluster["certificate-authority"] = ca.value.trim() + } else { + cluster["certificate-authority-data"] = PemUtils.toBase64(ca.value.trim()) + } + } + + val clusterEntry = mapOf( + "name" to clusterName, + "cluster" to cluster + ) + return clusterEntry +} + +private fun createUser(usingToken: Boolean, token: CharArray?, clientCert: CertificateSource?, clientKey: CertificateSource?): Map { + val user = mutableMapOf() + + if (usingToken + && token != null) { + setToken(token, user) + } else { + setClientCertificates(clientCert, clientKey, user) + } + + return mapOf( + "name" to userName, + "user" to user + ) +} + +private fun setToken(token: CharArray, user: MutableMap) { + user["token"] = String(token).trim() +} + +private fun setClientCertificates( + clientCert: CertificateSource?, + clientKey: CertificateSource?, + user: MutableMap +) { + clientCert?.let { cert -> + if (cert.isFilePath) { + user["client-certificate"] = cert.value.trim() + } else { + user["client-certificate-data"] = PemUtils.toBase64(cert.value.trim()) + } + } + clientKey?.let { key -> + if (key.isFilePath) { + user["client-key"] = key.value.trim() + } else { + user["client-key-data"] = PemUtils.toBase64(key.value.trim()) + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt index 8c0e3047..6d95a20f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -14,9 +14,8 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.openapi.progress.ProgressIndicator import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.auth.tls.TlsContext -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.Cluster -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import com.redhat.devtools.gateway.openshift.TlsClientBuilder import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient @@ -76,14 +75,13 @@ abstract class AbstractAuthenticationStrategy( token: String, tlsContext: TlsContext, ): ApiClient = - OpenShiftClientFactory(KubeConfigUtils).create( - server, - certificateAuthority = null, - token = token.toCharArray(), + TlsClientBuilder( + server = server, + token = token, clientCert = null, clientKey = null, tlsContext = tlsContext, - ) + ).build() /** * Creates a validated API client on a worker thread. @@ -105,15 +103,13 @@ abstract class AbstractAuthenticationStrategy( val certSource = resolveRequiredCertificateSource(clientCert) val keySource = resolveRequiredCertificateSource(clientKey) - OpenShiftClientFactory(KubeConfigUtils) - .create( - server, - certificateAuthority = null, - token?.toCharArray(), - certSource, - keySource, - tlsContext - ) + TlsClientBuilder( + server = server, + token = token, + clientCert = certSource, + clientKey = keySource, + tlsContext = tlsContext + ).build() .also { client -> if (probeApiAccess) { coroutineContext.ensureActive() diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt new file mode 100644 index 00000000..237e281d --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package com.redhat.devtools.gateway.openshift + +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.SslContextFactory +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import io.kubernetes.client.util.KubeConfig +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +class OpenShiftClientBuilderTest { + + private val tlsContext = SslContextFactory.insecure() + + /** Self-signed RSA fixture for this suite only (*.invalid); not from any cluster or public CA. */ + // notsecret + private val testClientCertPem = """ + -----BEGIN CERTIFICATE----- + MIIDlTCCAn2gAwIBAgIUJ/MyNwdZC5vGYJMyYa5m4letZrYwDQYJKoZIhvcNAQEL + BQAwWjEnMCUGA1UEAwweZmFrZS11bml0LXRlc3QuZXhhbXBsZS5pbnZhbGlkMSIw + IAYDVQQKDBlFeGFtcGxlIFRlc3QgRml4dHVyZSBPbmx5MQswCQYDVQQGEwJYWDAe + Fw0yNjA1MTMxMzE4MDJaFw0zNjA1MTAxMzE4MDJaMFoxJzAlBgNVBAMMHmZha2Ut + dW5pdC10ZXN0LmV4YW1wbGUuaW52YWxpZDEiMCAGA1UECgwZRXhhbXBsZSBUZXN0 + IEZpeHR1cmUgT25seTELMAkGA1UEBhMCWFgwggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQCG4CRbIkDOtpWzjWVW3V62FKzSfdAhdOJ/avqaPU2FiSjwEcBu + VceoT5ilVjNWuDSqWeTrmwPjBfzywpB9OHrziqE5rRBnlyuxTMgxxbpNU8WEBFtn + 2RWvKen0uZOOLTro1oQsI6ALqKd07s8t9XjIZMEiOzhvKzYK6xQiqXjnYJqWAw3Z + jhuvPcuvAALTXJMB6dASZNJ+q7gUd0gIMIjXVzAcj/QPxISwr3JMbpk+GvDnz0kF + t7TFQRMqW56dbK36ukjDvLdFd+bbigE6m55vsGVdyZC55wBIB87ycn0zc3hgrfej + 4JVEqEhhlsifUkjGqNR2h9cdY3u58gzJwZP5AgMBAAGjUzBRMB0GA1UdDgQWBBSn + 488Oxr0rTEaI1Q3xHhxERrAZ5jAfBgNVHSMEGDAWgBSn488Oxr0rTEaI1Q3xHhxE + RrAZ5jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAu0fWReMMg + SMM2ctyslZ/b00FDUnDq713HQ+HH3sB28NVxvwKUHR637Z/VzX2HNlR5wuR2ulxK + i6m54EBVCuE+T4kwPD/wx32RtGMAyuBlpamLC6WOdmVIVdYr66BRE7KdfTNnK+MJ + Aa0duD5KniqxkdMU7ZxveHM6RRv/hDg0qybOxLSwetmfI9CRiw0qOGiX5PhCqsJV + If1FxRl2mPPO0HiI94AyenmZfatuz9Y8Pb/q7cgdXpX2x29dnqXXO91qbVHk+zII + sYowqsdnMTfqNHFSJGrNovvI63/GQ/8148oKAALaH4VgNOyVIdaKkPDR5I/WBnNm + gJHFa/ozYnVi + -----END CERTIFICATE----- + """.trimIndent() + + // notsecret — PKCS#8 RSA key generated only for this test suite; not paired with a live cluster + private val testClientKeyPem = + "-----BEGIN PRIVATE KEY-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcxRFWa06rNo5xsdpGTLsETLviFAR4wdB0bylr6lHCuNpdW1gM1TyRvVvFyQWbJK+Dk+emSV7ocbUibUaxdhWQ1W1Jv7L/s3H4zzYdWpOF4LZ+W0wHVhav4AZjiU7GbvO15uK2gbfuEZHJ06uLTpKMh5uWRGpEBr0eNDE1Y6au1lpZtJSfgXuJRXHd+kbngjtmjb4XcW/3xCBbcAcpmXSCGgE9uV5uuYVmCwYBrLtHK5MUKz0i1F85XN2DEQwAEHEkg5d9Z0ypxoKHMRGmBkoN2t9SihAU04efHHKWk2GTDFZGY4Ga4w/YmmhoPM54gU7ONRN3LYRfThr0Y5ivJfPTAgMBAAECggEACqQ97GCAXeg9fG5BjirAjybToiG7DqS+t7NMBoKeENpncurea+Xq+fb2odMRdFnl0sgEHio2LQ/QPIlaa8rDj/9M2d1kvdgP0SnlAdJs19ZMd6tO2o33dZzUEjGh4p++ygbli39RXZxdCcGP5Xbsmml3dZh99ibW85PrZd+2fYD9hsO0CTRdlCLB0/Gy/yKW4iujlJyp1HfPFeiw/lKL5GwNSFpMJElwGcQUPVdXPqU+GzPJH1m54jFlYzIZCXuHW4U99+NPFj6foA5PjMS3ZXcEyWZfhuHbDYrqj3aKxRURWaNdGVxML6xSmdXvN/4yo7CkLUr1PN0apKACVS0FYQKBgQDUTP0aTp+ja3TNVtrv4K1v8Me3stlbxR9zeB/zL4QBjSkALBhz8xeYGYm4i1elH3Ch9jn2rHy9E8C7zxwZbW2mYHtV/Micyc6X03yqBuEVzsqlIxSUoUKM4yVTlje5jj6ggo/OJP86wUExbvsxkjocjrRitqk5eAlt6KHr5SxbqQKBgQC9CewRCFBJQpYaHDEpyVHlsgM6qwP4W4VvStScTQ1hXnHE7g0mQhKxiS+WgF6RJkhiJhTvRfSVm0/3PSLa9woEtgiNx+cPscHLFvR0y4RCbjA1QDIGLbQV9/e6ntnlup4nFrCEgA17oQtb/EGXMAIRL2SdsGpd3YEWrSchOxuhGwKBgQDJeyt17Qo6OMAIJJbxswRGyXdxUm5QVtsLZgTEceLQ6hvwSukGGb3ZntsCZlPOpPDq9Nh7z6UueHGgi+U6CI1YqhZDO/1UN342vwKABrlVTgUqBgoBKK4VMXl6Q4UtN98dy+sYlCoZo9DwTkhc+k7mTVTKnlop7U7dnTsWuk+HyQKBgHOAm39wr/WDPMlpTlS00FhjIvv2v+9ApE/yzeNOZQ2IMkVcGia1GkzlgHEZsC5J0NI/aG0mNiIvCnYLIb/eT32/Z4yRhsmdF8aqGOU/8GjSgJwYxDfoNu9xWijppENsefNyNppOz24pYRJsF/tzdt/fMD/1KZh+ncAoPg9c2S3fAoGAWzYz9FFDIXv8yx8e5eGJstq+F2GkOrTliPfjX5PP1NkIJ8vFxGVE6RKzn8FSoE+Xxz5GjcULoE0hno7p2oYqLQpd7pI3LyLTSZhTN0FKDHQQpPtzoo6hSda53i3AaI0VO6mRi3VJaSoWhUkz/4ULR1NuuWpW2oFD2hIEQZqkiDI=-----END PRIVATE KEY-----" + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `TlsClientBuilder sets basePath for token auth`() { + val client = TlsClientBuilder( + server = "https://api.example.com:6443/", + token = "test-token", // notsecret + tlsContext = tlsContext, + ).build() + + assertThat(client.basePath).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `TlsClientBuilder sets basePath for client certificate auth`() { + val client = TlsClientBuilder( + server = "https://api.example.com:6443/", + clientCert = CertificateSource.fromData(testClientCertPem), + clientKey = CertificateSource.fromData(testClientKeyPem), + tlsContext = tlsContext, + ).build() + + assertThat(client.basePath).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `TlsClientBuilder rejects missing auth`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TlsClientBuilder rejects both token and client certificate`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + token = "test-token", // notsecret + clientCert = CertificateSource.fromData(testClientCertPem), + clientKey = CertificateSource.fromData(testClientKeyPem), + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TlsClientBuilder rejects client certificate without key`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + clientCert = CertificateSource.fromData(testClientCertPem), + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TokenClientBuilder applies read timeout`() { + val client = TokenClientBuilder("https://api.example.com:6443", "test-token") // notsecret + .readTimeout(45, TimeUnit.SECONDS) + .build() + + assertThat(client.httpClient.readTimeoutMillis).isEqualTo(45_000) + } + + @Test + fun `TokenClientBuilder rejects empty token`() { + assertThatThrownBy { + TokenClientBuilder("https://api.example.com:6443", "") + .build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `DefaultClientBuilder falls back when no kubeconfig files exist`() { + val configUtils = mockk() + every { configUtils.getAllConfigFiles() } returns emptyList() + + runCatching { DefaultClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 0) { configUtils.getAllConfigs(any()) } + } + + @Test + fun `DefaultClientBuilder falls back when kubeconfig merge fails`() { + val configUtils = mockk() + val configPath = mockk() + every { configUtils.getAllConfigFiles() } returns listOf(configPath) + every { configUtils.getAllConfigs(listOf(configPath)) } throws RuntimeException("invalid yaml") + + runCatching { DefaultClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 1) { configUtils.getAllConfigs(listOf(configPath)) } + verify(exactly = 0) { configUtils.mergeConfigs(any()) } + } + + @Test + fun `DefaultClientBuilder builds from merged kubeconfig`() { + val configUtils = mockk() + val configPath = mockk() + val kubeConfig = KubeConfig( + arrayListOf( + mapOf( + "name" to "test-context", + "context" to mapOf( + "cluster" to "test-cluster", + "user" to "test-user", + ), + ), + ), + arrayListOf( + mapOf( + "name" to "test-cluster", + "cluster" to mapOf("server" to "https://merged.example.com:6443"), + ), + ), + arrayListOf( + mapOf( + "name" to "test-user", + "user" to mapOf("token" to "merged-token"), // notsecret + ), + ), + ) + kubeConfig.setContext("test-context") + + every { configUtils.getAllConfigFiles() } returns listOf(configPath) + every { configUtils.getAllConfigs(listOf(configPath)) } returns listOf(kubeConfig) + every { configUtils.mergeConfigs(listOf(kubeConfig)) } returns kubeConfig + + val client = DefaultClientBuilder(configUtils).build() + + assertThat(client.basePath).isEqualTo("https://merged.example.com:6443") + } +} diff --git a/terminal-to-che.sh b/terminal-to-che.sh new file mode 100755 index 00000000..906c199e --- /dev/null +++ b/terminal-to-che.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +pattern="${1:-workspace}" + +kubectl exec -it $(kubectl get pod | grep -o "${pattern}\S\+" | head -n 1) -- bash From d623074561678b08770239cffce99368f9a17838 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 16 Jun 2026 21:22:48 +0200 Subject: [PATCH 04/10] refactor: removed duplicate http request codes Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .../auth/code/OpenShiftAuthCodeFlow.kt | 214 ++++++++---------- 1 file changed, 92 insertions(+), 122 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index 42b37084..ff6547f9 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -19,11 +19,11 @@ import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce import com.redhat.devtools.gateway.util.toServerBaseUrl -import kotlinx.coroutines.* import kotlinx.coroutines.future.await import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import java.lang.Void import java.net.URI import java.net.URLDecoder import java.net.URLEncoder @@ -49,8 +49,6 @@ class OpenShiftAuthCodeFlow( private lateinit var metadata: OAuthMetadata - private val json = Json { ignoreUnknownKeys = true } - private val discoveryClient: HttpClient by lazy { HttpClient.newBuilder() .sslContext(sslContext) @@ -70,16 +68,14 @@ class OpenShiftAuthCodeFlow( @Serializable private data class OAuthMetadata( val issuer: String, - @SerialName("authorization_endpoint") val authorizationEndpoint: String, - @SerialName("token_endpoint") val tokenEndpoint: String ) companion object { - private val discoveryJson = Json { ignoreUnknownKeys = true } + private val json = Json { ignoreUnknownKeys = true } /** OAuth HTTP endpoint base URLs discovered from the API server. */ suspend fun discoverOAuthEndpointBaseUrls( @@ -92,20 +88,53 @@ class OpenShiftAuthCodeFlow( .followRedirects(HttpClient.Redirect.NORMAL) .build() + val response = sendGetRequest(client, "$apiServerUrl/.well-known/oauth-authorization-server", "OAuth discovery failed") + val metadata = json.decodeFromString(OAuthMetadata.serializer(), response.body()) + return listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) + .map { URI(it).toServerBaseUrl() } + .distinct() + } + + private suspend fun sendGetRequest(httpClient: HttpClient, url: String, errorPrefix: String = "Request failed"): HttpResponse { val request = HttpRequest.newBuilder() - .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) + .uri(URI.create(url)) .GET() .build() - val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + val response = httpClient.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ).await() if (response.statusCode() !in 200..299) { - error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") } + return response + } - val metadata = discoveryJson.decodeFromString(OAuthMetadata.serializer(), response.body()) - return listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) - .map { URI(it).toServerBaseUrl() } - .distinct() + private suspend fun sendPostRequest( + httpClient: HttpClient, + url: String, + authHeader: String, + formBody: String, + errorPrefix: String = "Request failed" + ): AccessTokenResponseJson { + val request = HttpRequest.newBuilder() + .uri(URI(url)) + .header("Authorization", authHeader) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build() + + val response = httpClient.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ).await() + if (response.statusCode() !in 200..299) { + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") + } + + return json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) } } @@ -113,18 +142,7 @@ class OpenShiftAuthCodeFlow( * Discover OAuth endpoints from the cluster. */ private suspend fun discoverOAuthMetadata(): OAuthMetadata { - val client = discoveryClient - - val request = HttpRequest.newBuilder() - .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) - .GET() - .build() - - val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() !in 200..299) { - error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") - } - + val response = sendGetRequest(discoveryClient, "$apiServerUrl/.well-known/oauth-authorization-server") return json.decodeFromString(OAuthMetadata.serializer(), response.body()) } @@ -157,8 +175,8 @@ class OpenShiftAuthCodeFlow( override suspend fun handleCallback(parameters: Parameters): SSOToken { val code: String = parameters["code"] ?: error("Missing 'code' parameter in callback") - - return exchangeCodeForToken(code) + val uri = redirectUri ?: error("redirectUri is required for code exchange") + return exchangeCodeForToken(code, discoveryClient, "openshift-cli-client", uri, accountLabel = "openshift-user") } private fun encodeForm(vararg pairs: Pair): String = @@ -167,43 +185,35 @@ class OpenShiftAuthCodeFlow( URLEncoder.encode(v, StandardCharsets.UTF_8) } - private suspend fun exchangeCodeForToken(code: String): SSOToken { - val httpClient = discoveryClient + private fun parseRedirectQuery(location: String): Map { + val query = URI(location).query ?: error("Missing query in redirect") + return query.split("&") + .map { it.split("=", limit = 2) } + .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + } - val basicAuth = "Basic " + Base64.getEncoder() - .encodeToString("openshift-cli-client:".toByteArray(StandardCharsets.UTF_8)) + private suspend fun exchangeCodeForToken( + code: String, + client: HttpClient, + clientId: String, + redirectUri: URI, + clientIdInForm: Boolean = true, + accountLabel: String = "", + ): SSOToken { + val authHeader = "Basic " + Base64.getEncoder() + .encodeToString("$clientId:".toByteArray(StandardCharsets.UTF_8)) val form = encodeForm( "grant_type" to "authorization_code", - "client_id" to "openshift-cli-client", "code" to code, + "code_verifier" to codeVerifier.value, "redirect_uri" to redirectUri.toString(), - "code_verifier" to codeVerifier.value + *if (clientIdInForm) arrayOf("client_id" to clientId) else emptyArray() ) - val request = HttpRequest.newBuilder() - .uri(URI(metadata.tokenEndpoint)) - .header("Authorization", basicAuth) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() - - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() !in 200..299) { - error("Token request failed: ${response.statusCode()}\n${response.body()}") - } - - val token = json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) - val expiresAt = - if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null - - return SSOToken( - accessToken = token.accessToken, - idToken = "", - accountLabel = "openshift-user", - expiresAt = expiresAt - ) + val token = sendPostRequest(client, metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + return SSOToken(accessToken = token.accessToken, idToken = "", accountLabel = accountLabel, expiresAt = expiresAt) } override suspend fun login(parameters: Parameters): SSOToken { @@ -214,8 +224,6 @@ class OpenShiftAuthCodeFlow( codeVerifier = CodeVerifier() state = State() - val httpClient = noRedirectClient - val redirectUri = URI( metadata.tokenEndpoint.replace( "/oauth/token", @@ -235,42 +243,15 @@ class OpenShiftAuthCodeFlow( val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) - // First request (expect 401) - var request = HttpRequest.newBuilder() - .uri(authorizeUri) - .header("X-Csrf-Token", "1") - .GET() - .build() - - var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - - // Retry with Basic auth - if (response.statusCode() == 401) { - request = HttpRequest.newBuilder() - .uri(authorizeUri) - .header("Authorization", basicAuth) - .header("X-Csrf-Token", "1") - .GET() - .build() - - response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - } - - if (response.statusCode() !in listOf(302, 303)) { - error("Authorization failed: ${response.statusCode()}") - } + val response = sendWithRetryOn401(noRedirectClient, authorizeUri, basicAuth) val location = response.headers().firstValue("Location") .orElseThrow { error("Missing redirect Location header") } - val redirectedUri = URI(location) - val query = redirectedUri.query ?: error("Missing query in redirect") - val params = query.split("&") - .map { it.split("=", limit = 2) } - .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + val params = parseRedirectQuery(location) val code = params["code"] ?: error("Authorization code not found in redirect") - val token = exchangeCodeForTokenWithBasicAuth(httpClient, code = code, redirectUri = redirectUri) + val token = exchangeCodeForToken(code, noRedirectClient, "openshift-challenging-client", redirectUri, clientIdInForm = false) return SSOToken( accessToken = token.accessToken, @@ -280,45 +261,34 @@ class OpenShiftAuthCodeFlow( ) } - private suspend fun exchangeCodeForTokenWithBasicAuth( - httpClient: HttpClient, - code: String, - redirectUri: URI - ): SSOToken { - val clientAuth = "Basic " + Base64.getEncoder() - .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) + private suspend fun sendWithRetryOn401( + client: HttpClient, + authorizeUri: URI, + basicAuth: String + ): HttpResponse { + var request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("X-Csrf-Token", "1") + .GET() + .build() - val form = encodeForm( - "grant_type" to "authorization_code", - "code" to code, - "redirect_uri" to redirectUri.toString(), - "code_verifier" to codeVerifier.value - ) + var response = client.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - val request = HttpRequest.newBuilder() - .uri(URI(metadata.tokenEndpoint)) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Authorization", clientAuth) - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() + if (response.statusCode() == 401) { + request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("Authorization", basicAuth) + .header("X-Csrf-Token", "1") + .GET() + .build() - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() != 200) { - error("Token exchange failed: ${response.statusCode()} ${response.body()}") + response = client.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() } - val token = json.decodeFromString( - AccessTokenResponseJson.serializer(), - response.body() - ) - val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + if (response.statusCode() !in listOf(302, 303)) { + error("Authorization failed: ${response.statusCode()}") + } - return SSOToken( - accessToken = token.accessToken, - idToken = "", - accountLabel = "", - expiresAt = expiresAt - ) + return response } } From f77c89e0b169d2468ae34e785a83f0bd85fda049 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 11:23:21 +0200 Subject: [PATCH 05/10] refactor: extracted #getTrustedCerts for better readable code Signed-off-by: Andre Dietisheim --- .../auth/tls/DefaultTlsTrustManager.kt | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index cc13aaa7..c432a753 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -15,7 +15,8 @@ import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster import com.redhat.devtools.gateway.util.toServerBaseUrl import io.kubernetes.client.util.KubeConfig -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.net.URI import java.security.cert.X509Certificate import javax.net.ssl.SSLHandshakeException @@ -44,24 +45,7 @@ class DefaultTlsTrustManager( return SslContextFactory.insecure() } - val sessionCerts = sessionTrustStore.allCertificates() - val trustedCerts = mutableListOf() - - // Stale kubeconfig or persistent trust must not override session trust from this wizard. - if (sessionCerts.isEmpty()) { - namedCluster?.let { - trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) - } - - val keyStore = persistentKeyStore.loadOrCreate() - val persistentCert = keyStore.getCertificate("host:${serverUri.host}") - if (persistentCert is X509Certificate) { - trustedCerts += persistentCert - } - } - - trustedCerts += sessionTrustStore.get(serverUrl) - trustedCerts += sessionCerts + val trustedCerts = getTrustedCerts(namedCluster, serverUri.host) + sessionTrustStore.get(serverUrl) if (trustedCerts.isNotEmpty()) { try { @@ -109,7 +93,7 @@ class DefaultTlsTrustManager( } val keyStore = persistentKeyStore.loadOrCreate() - val persistentAlias = "host:${serverUri.host}" + val persistentAlias = hostAlias(serverUri.host) when (decision.scope) { TlsTrustScope.SESSION_ONLY -> { @@ -181,7 +165,7 @@ class DefaultTlsTrustManager( KubeConfigTlsUtils.findClusterByServer(serverUrl, configs)?.let { allCerts += KubeConfigTlsUtils.extractCaCertificates(it) } - val persistentCert = keyStore.getCertificate("host:${uri.host}") + val persistentCert = keyStore.getCertificate(hostAlias(uri.host)) if (persistentCert is X509Certificate) { allCerts += persistentCert } @@ -194,6 +178,42 @@ class DefaultTlsTrustManager( return SslContextFactory.fromTrustedCerts(allCerts.distinctBy { it.serialNumber }) } + /** + * Returns the list of trusted X.509 certificates for a server URL. + * + *

Session trust (from TLS wizard) takes precedence over stale kubeconfig or persistent store entries. + * If session certificates are present, they are added without duplicates. Otherwise, CA certificates + * from the named cluster and any persisted certificate for the host are added.

+ * + * @param namedCluster The optional Kubernetes cluster configuration from kubeconfig + * @param host The hostname to look up in the persistent keystore (without scheme) + * @return List of X.509 certificates to trust for TLS verification + */ + private fun getTrustedCerts(namedCluster: KubeConfigNamedCluster?, host: String): List { + val sessionCerts = sessionTrustStore.allCertificates() + + return buildList { + if (sessionCerts.isEmpty()) { + namedCluster?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + val persistentCert = persistentKeyStore.loadOrCreate() + .getCertificate(hostAlias(host)) + if (persistentCert is X509Certificate) { + add(persistentCert) + } + } else { + sessionCerts.forEach { cert -> + if (cert !in this) { + add(cert) + } + } + } + } + } + + private fun hostAlias(host: String) = "host:$host" + private fun sha256Fingerprint(cert: X509Certificate): String { val digest = java.security.MessageDigest.getInstance("SHA-256") .digest(cert.encoded) From 5bb48b91cd50643f671b9fab78ffae9b515c7ee7 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 12:13:57 +0200 Subject: [PATCH 06/10] refactor: move cluster-by-server lookup to KubeConfigUtils Centralize kubeconfig cluster resolution in KubeConfigUtils as getClusterByServer and drop the duplicate from KubeConfigTlsUtils. Signed-off-by: Andre Dietisheim --- .../gateway/auth/tls/DefaultTlsTrustManager.kt | 5 +++-- .../devtools/gateway/auth/tls/KubeConfigTlsUtils.kt | 10 ---------- .../devtools/gateway/kubeconfig/KubeConfigUtils.kt | 9 +++++++++ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index c432a753..24da3d5a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.util.toServerBaseUrl import io.kubernetes.client.util.KubeConfig import kotlinx.coroutines.Dispatchers @@ -36,7 +37,7 @@ class DefaultTlsTrustManager( val serverUri = URI(serverUrl) val namedCluster = - KubeConfigTlsUtils.findClusterByServer( + KubeConfigUtils.getClusterByServer( serverUrl, kubeConfigProvider() ) @@ -162,7 +163,7 @@ class DefaultTlsTrustManager( for (serverUrl in serverUrls.distinct()) { val uri = URI(serverUrl) if (sessionCerts.isEmpty()) { - KubeConfigTlsUtils.findClusterByServer(serverUrl, configs)?.let { + KubeConfigUtils.getClusterByServer(serverUrl, configs)?.let { allCerts += KubeConfigTlsUtils.extractCaCertificates(it) } val persistentCert = keyStore.getCertificate(hostAlias(uri.host)) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt index e1741037..b4abdebf 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster -import io.kubernetes.client.util.KubeConfig import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 @@ -21,15 +20,6 @@ import kotlin.io.path.readText object KubeConfigTlsUtils { - fun findClusterByServer( - serverUrl: String, - kubeConfigs: List - ): KubeConfigNamedCluster? = - kubeConfigs - .flatMap { it.clusters ?: emptyList() } - .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } - .firstOrNull { it.cluster.server == serverUrl } - fun extractCaCertificates( namedCluster: KubeConfigNamedCluster ): List { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt index 87d9ab14..024a8076 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -34,6 +34,15 @@ object KubeConfigUtils { return currentUser?.user?.token != null } + fun getClusterByServer( + serverUrl: String, + kubeConfigs: List + ): KubeConfigNamedCluster? = + kubeConfigs + .flatMap { it.clusters ?: emptyList() } + .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } + .firstOrNull { it.cluster.server == serverUrl } + fun getClusters(kubeconfigPaths: List): List { logger.info("Getting clusters from kubeconfig paths: $kubeconfigPaths") val kubeConfigs = toKubeConfigs(kubeconfigPaths) From 5ed66be46fedeb23a61f1a5ce9ec5a919d5a64ff Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 12:16:53 +0200 Subject: [PATCH 07/10] fix: wire wizard CA field into TLS trust resolution Use the Certificate Authority input when establishing TLS context so user-provided paths and PEM data are honored before the trust dialog. Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .../auth/tls/DefaultTlsTrustManager.kt | 32 ++++++-- .../gateway/auth/tls/KubeConfigTlsUtils.kt | 8 +- .../gateway/auth/tls/TlsTrustManager.kt | 3 +- .../view/steps/DevSpacesServerStepView.kt | 43 +++++++---- .../view/steps/auth/AuthenticationStrategy.kt | 5 +- ...ClientCertificateAuthenticationStrategy.kt | 1 - ...nShiftCredentialsAuthenticationStrategy.kt | 1 - .../OpenShiftOAuthAuthenticationStrategy.kt | 1 - .../auth/RedHatSSOAuthenticationStrategy.kt | 1 - .../steps/auth/TokenAuthenticationStrategy.kt | 1 - .../auth/tls/DefaultTlsTrustManagerCaTest.kt | 76 +++++++++++++++++++ .../auth/tls/KubeConfigTlsUtilsTest.kt | 71 +++++++++++++++++ .../gateway/auth/tls/TlsTestCertificates.kt | 48 ++++++++++++ 13 files changed, 258 insertions(+), 33 deletions(-) create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 24da3d5a..20a54a5d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -31,7 +31,8 @@ class DefaultTlsTrustManager( override suspend fun ensureTrusted( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource?, ): TlsContext { val serverUri = URI(serverUrl) @@ -46,7 +47,8 @@ class DefaultTlsTrustManager( return SslContextFactory.insecure() } - val trustedCerts = getTrustedCerts(namedCluster, serverUri.host) + sessionTrustStore.get(serverUrl) + val trustedCerts = getTrustedCerts(namedCluster, serverUri.host, certificateAuthority) + + sessionTrustStore.get(serverUrl) if (trustedCerts.isNotEmpty()) { try { @@ -131,12 +133,13 @@ class DefaultTlsTrustManager( suspend fun ensureOpenShiftTlsContext( apiServerUrl: String, decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, ): TlsContext { val apiBaseUrl = URI(apiServerUrl).toServerBaseUrl() - ensureTrusted(apiBaseUrl, decisionHandler) + ensureTrusted(apiBaseUrl, decisionHandler, certificateAuthority) - val apiTls = mergedContextFor(listOf(apiBaseUrl)) + val apiTls = mergedContextFor(listOf(apiBaseUrl), certificateAuthority) val oauthUrls = runCatching { OpenShiftAuthCodeFlow.discoverOAuthEndpointBaseUrls( apiBaseUrl, @@ -147,14 +150,17 @@ class DefaultTlsTrustManager( val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() for (url in allUrls) { if (url != apiBaseUrl) { - ensureTrusted(url, decisionHandler) + ensureTrusted(url, decisionHandler, certificateAuthority) } } - return mergedContextFor(allUrls) + return mergedContextFor(allUrls, certificateAuthority) } - suspend fun mergedContextFor(serverUrls: Collection): TlsContext { + suspend fun mergedContextFor( + serverUrls: Collection, + certificateAuthority: CertificateSource? = null, + ): TlsContext { val configs = kubeConfigProvider() val keyStore = persistentKeyStore.loadOrCreate() val allCerts = mutableListOf() @@ -163,6 +169,9 @@ class DefaultTlsTrustManager( for (serverUrl in serverUrls.distinct()) { val uri = URI(serverUrl) if (sessionCerts.isEmpty()) { + certificateAuthority?.let { + allCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } KubeConfigUtils.getClusterByServer(serverUrl, configs)?.let { allCerts += KubeConfigTlsUtils.extractCaCertificates(it) } @@ -190,11 +199,18 @@ class DefaultTlsTrustManager( * @param host The hostname to look up in the persistent keystore (without scheme) * @return List of X.509 certificates to trust for TLS verification */ - private fun getTrustedCerts(namedCluster: KubeConfigNamedCluster?, host: String): List { + private fun getTrustedCerts( + namedCluster: KubeConfigNamedCluster?, + host: String, + certificateAuthority: CertificateSource?, + ): List { val sessionCerts = sessionTrustStore.allCertificates() return buildList { if (sessionCerts.isEmpty()) { + certificateAuthority?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } namedCluster?.let { addAll(KubeConfigTlsUtils.extractCaCertificates(it)) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt index b4abdebf..04cf43aa 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -22,8 +22,12 @@ object KubeConfigTlsUtils { fun extractCaCertificates( namedCluster: KubeConfigNamedCluster - ): List { - val caSource = namedCluster.cluster.certificateAuthority ?: return emptyList() + ): List = + namedCluster.cluster.certificateAuthority + ?.let(::extractCaCertificates) + .orEmpty() + + fun extractCaCertificates(caSource: CertificateSource): List { val caContent = try { if (caSource.isFilePath) { caSource.toPath().readText() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt index 1a13d5bb..047e490d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt @@ -21,6 +21,7 @@ interface TlsTrustManager { */ suspend fun ensureTrusted( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, ): TlsContext } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 5d2e9ae4..e61d1c01 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -399,15 +399,17 @@ class DevSpacesServerStepView( try { indicator.text = "Establishing secure connection..." - val tlsContext = resolveTlsContext(server, strategy.getAuthMethod()) + val certificateAuthority = resolveCertificateAuthority(tfCertAuthority.text) + val tlsContext = resolveTlsContext( + server, + strategy.getAuthMethod(), + certificateAuthority, + ) indicator.text = "Connecting to cluster..." - val certAuthorityData = tfCertAuthority.text.ifBlank { null } - strategy.authenticate( selectedCluster, server, - certAuthorityData, tlsContext, devSpacesContext, indicator @@ -509,26 +511,41 @@ class DevSpacesServerStepView( persistentKeyStore = persistentKeyStore ) - private suspend fun resolveTlsContext(serverUrl: String, authMethod: AuthMethod): TlsContext { + private suspend fun resolveTlsContext( + serverUrl: String, + authMethod: AuthMethod, + certificateAuthority: CertificateSource?, + ): TlsContext { return when (authMethod) { - AuthMethod.OPENSHIFT, AuthMethod.OPENSHIFT_CREDENTIALS -> + AuthMethod.OPENSHIFT, + AuthMethod.OPENSHIFT_CREDENTIALS -> tlsTrustManager.ensureOpenShiftTlsContext( - apiServerUrl = serverUrl, - decisionHandler = UITlsDecisionAdapter::decide, + serverUrl, + UITlsDecisionAdapter::decide, + certificateAuthority, ) - else -> - resolveSslContext(serverUrl) + resolveSslContext(serverUrl, certificateAuthority) } } - private suspend fun resolveSslContext(serverUrl: String): TlsContext { + private suspend fun resolveSslContext( + serverUrl: String, + certificateAuthority: CertificateSource?, + ): TlsContext { return tlsTrustManager.ensureTrusted( - serverUrl = serverUrl, - decisionHandler = UITlsDecisionAdapter::decide + serverUrl, + UITlsDecisionAdapter::decide, + certificateAuthority, ) } + private fun resolveCertificateAuthority(input: String): CertificateSource? { + val source = CertificateSource.fromPathOrPem(input) ?: return null + source.validate() + return source + } + private suspend fun saveKubeconfig(cluster: Cluster, token: String, indicator: ProgressIndicator) { if (!saveConfig || token.isBlank()) return diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt index 849f6203..0834d3f4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt @@ -44,16 +44,13 @@ interface AuthenticationStrategy { * * @param selectedCluster The cluster to authenticate against * @param server The server URL - * @param certAuthority The certificate authority data * @param tlsContext The TLS context for secure connections - * @param indicator The progress indicator * @param devSpacesContext The DevSpaces context to update - * @return true if authentication succeeded, false otherwise + * @param indicator The progress indicator */ suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt index a5e7b7e7..c7718d5c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -76,7 +76,6 @@ class ClientCertificateAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt index e06ddb55..664ea8bd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -104,7 +104,6 @@ class OpenShiftCredentialsAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt index 1e79909d..625b23b8 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -52,7 +52,6 @@ class OpenShiftOAuthAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt index c8382014..db8c2582 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -51,7 +51,6 @@ class RedHatSSOAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt index 4cceb6b6..c0aa7093 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt @@ -85,7 +85,6 @@ class TokenAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt new file mode 100644 index 00000000..0f21ba06 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.nio.file.Files +import javax.net.ssl.X509TrustManager + +class DefaultTlsTrustManagerCaTest { + + private val serverUrl = "https://api.example.com:6443" + + private fun createManager( + sessionTrustStore: SessionTlsTrustStore = SessionTlsTrustStore(), + ): DefaultTlsTrustManager { + val persistentPath = Files.createTempDirectory("tls-trust").resolve("truststore.p12") + return DefaultTlsTrustManager( + kubeConfigProvider = { emptyList() }, + kubeConfigWriter = { _, _ -> }, + sessionTrustStore = sessionTrustStore, + persistentKeyStore = PersistentKeyStore(persistentPath), + ) + } + + @Test + fun `#mergedContextFor includes wizard certificate authority`() { + runBlocking { + val expectedCert = TlsTestCertificates.caCertificate() + val manager = createManager() + + val tlsContext = manager.mergedContextFor( + listOf(serverUrl), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).contains(expectedCert.serialNumber) + } + } + + @Test + fun `#mergedContextFor uses session trust when certificates already accepted`() { + runBlocking { + val sessionCert = TlsTestCertificates.caCertificate() + val sessionStore = SessionTlsTrustStore().apply { + put(serverUrl, listOf(sessionCert)) + } + val manager = createManager(sessionTrustStore = sessionStore) + + val tlsContext = manager.mergedContextFor( + listOf(serverUrl), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).containsExactly(sessionCert.serialNumber) + } + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt new file mode 100644 index 00000000..389d698f --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import com.redhat.devtools.gateway.kubeconfig.KubeConfigCluster +import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.nio.file.Files + +class KubeConfigTlsUtilsTest { + + @Test + fun `#extractCaCertificates parses certificate-authority-data`() { + val source = TlsTestCertificates.caSourceFromData() + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().serialNumber) + .isEqualTo(TlsTestCertificates.caCertificate().serialNumber) + } + + @Test + fun `#extractCaCertificates reads certificate-authority file path`() { + val tempFile = Files.createTempFile("test-ca", ".pem") + tempFile.toFile().writeText(TlsTestCertificates.CA_PEM) + val source = CertificateSource.fromPath(tempFile.toString()) + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().subjectX500Principal.name) + .contains("CN=fake-unit-test.example.invalid") + } + + @Test + fun `#extractCaCertificates returns empty list for invalid data`() { + val source = CertificateSource.fromData("not-a-valid-cert") // notsecret + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).isEmpty() + } + + @Test + fun `#extractCaCertificates delegates from named cluster`() { + val namedCluster = KubeConfigNamedCluster( + name = "test", + cluster = KubeConfigCluster( + server = "https://api.example.com:6443", + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ), + ) + + val certificates = KubeConfigTlsUtils.extractCaCertificates(namedCluster) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().serialNumber) + .isEqualTo(TlsTestCertificates.caCertificate().serialNumber) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt new file mode 100644 index 00000000..557a43a4 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import java.security.cert.X509Certificate + +object TlsTestCertificates { + + // notsecret — synthetic self-signed fixture (see PemUtilsTest) + val CA_PEM: String = """ + -----BEGIN CERTIFICATE----- + MIIDlTCCAn2gAwIBAgIUJ/MyNwdZC5vGYJMyYa5m4letZrYwDQYJKoZIhvcNAQEL + BQAwWjEnMCUGA1UEAwweZmFrZS11bml0LXRlc3QuZXhhbXBsZS5pbnZhbGlkMSIw + IAYDVQQKDBlFeGFtcGxlIFRlc3QgRml4dHVyZSBPbmx5MQswCQYDVQQGEwJYWDAe + Fw0yNjA1MTMxMzE4MDJaFw0zNjA1MTAxMzE4MDJaMFoxJzAlBgNVBAMMHmZha2Ut + dW5pdC10ZXN0LmV4YW1wbGUuaW52YWxpZDEiMCAGA1UECgwZRXhhbXBsZSBUZXN0 + IEZpeHR1cmUgT25seTELMAkGA1UEBhMCWFgwggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQCG4CRbIkDOtpWzjWVW3V62FKzSfdAhdOJ/avqaPU2FiSjwEcBu + VceoT5ilVjNWuDSqWeTrmwPjBfzywpB9OHrziqE5rRBnlyuxTMgxxbpNU8WEBFtn + 2RWvKen0uZOOLTro1oQsI6ALqKd07s8t9XjIZMEiOzhvKzYK6xQiqXjnYJqWAw3Z + jhuvPcuvAALTXJMB6dASZNJ+q7gUd0gIMIjXVzAcj/QPxISwr3JMbpk+GvDnz0kF + t7TFQRMqW56dbK36ukjDvLdFd+bbigE6m55vsGVdyZC55wBIB87ycn0zc3hgrfej + 4JVEqEhhlsifUkjGqNR2h9cdY3u58gzJwZP5AgMBAAGjUzBRMB0GA1UdDgQWBBSn + 488Oxr0rTEaI1Q3xHhxERrAZ5jAfBgNVHSMEGDAWgBSn488Oxr0rTEaI1Q3xHhxE + RrAZ5jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAu0fWReMMg + SMM2ctyslZ/b00FDUnDq713HQ+HH3sB28NVxvwKUHR637Z/VzX2HNlR5wuR2ulxK + i6m54EBVCuE+T4kwPD/wx32RtGMAyuBlpamLC6WOdmVIVdYr66BRE7KdfTNnK+MJ + Aa0duD5KniqxkdMU7ZxveHM6RRv/hDg0qybOxLSwetmfI9CRiw0qOGiX5PhCqsJV + If1FxRl2mPPO0HiI94AyenmZfatuz9Y8Pb/q7cgdXpX2x29dnqXXO91qbVHk+zII + sYowqsdnMTfqNHFSJGrNovvI63/GQ/8148oKAALaH4VgNOyVIdaKkPDR5I/WBnNm + gJHFa/ozYnVi + -----END CERTIFICATE----- + """.trimIndent() + + fun caCertificate(): X509Certificate = PemUtils.parseCertificate(CA_PEM) + + fun caSourceFromData(): CertificateSource = + CertificateSource.fromData(PemUtils.toBase64(CA_PEM)) +} From 6042732d48b840d1d7922eab8c544b118e2f0c3c Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 14:02:12 +0200 Subject: [PATCH 08/10] fix: show sequential TLS trust dialogs during OpenShift connect Parent trust prompts to the wizard, use invokeLater instead of invokeAndWait, and split TLS setup from authentication so API and OAuth certificates can both be accepted. Add TLS trust logging and surface OAuth discovery failures. Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- io/kubernetes/client/openapi/ApiClient.class | Bin 39244 -> 0 bytes .../auth/code/OpenShiftAuthCodeFlow.kt | 27 +++- .../auth/tls/DefaultTlsTrustManager.kt | 86 ++++++++++-- .../gateway/auth/tls/TlsEndpointKind.kt | 19 +++ .../auth/tls/TlsServerCertificateInfo.kt | 3 +- .../gateway/auth/tls/TlsTrustManager.kt | 1 + .../auth/tls/ui/TLSTrustDecisionHandler.kt | 10 +- .../auth/tls/ui/UITlsDecisionAdapter.kt | 38 +++++- .../devtools/gateway/util/ExceptionUtils.kt | 11 ++ .../view/steps/DevSpacesServerStepView.kt | 129 ++++++++++++------ .../gateway/util/ExceptionUtilsTest.kt | 33 +++++ 11 files changed, 297 insertions(+), 60 deletions(-) delete mode 100644 io/kubernetes/client/openapi/ApiClient.class create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsEndpointKind.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt diff --git a/io/kubernetes/client/openapi/ApiClient.class b/io/kubernetes/client/openapi/ApiClient.class deleted file mode 100644 index 1a6fd3664156fa149cc1e8d7f299db7e826d587d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39244 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 z 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 diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index ff6547f9..64fe7d43 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -18,6 +18,7 @@ import com.nimbusds.oauth2.sdk.id.State import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce +import com.intellij.openapi.diagnostic.logger import com.redhat.devtools.gateway.util.toServerBaseUrl import kotlinx.coroutines.future.await import kotlinx.serialization.SerialName @@ -44,6 +45,8 @@ class OpenShiftAuthCodeFlow( private val sslContext: SSLContext ) : AuthCodeFlow { + private val logger = logger() + private lateinit var codeVerifier: CodeVerifier private lateinit var state: State @@ -75,6 +78,7 @@ class OpenShiftAuthCodeFlow( ) companion object { + private val logger = logger() private val json = Json { ignoreUnknownKeys = true } /** OAuth HTTP endpoint base URLs discovered from the API server. */ @@ -82,17 +86,29 @@ class OpenShiftAuthCodeFlow( apiServerUrl: String, sslContext: SSLContext, ): List { + val discoveryUrl = "$apiServerUrl/.well-known/oauth-authorization-server" + logger.info("TLS trust: discovering OAuth endpoints from $discoveryUrl") val client = HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build() - val response = sendGetRequest(client, "$apiServerUrl/.well-known/oauth-authorization-server", "OAuth discovery failed") + val response = try { + sendGetRequest(client, discoveryUrl, "OAuth discovery failed") + } catch (e: Exception) { + logger.error("TLS trust: OAuth discovery request to $discoveryUrl failed", e) + throw e + } val metadata = json.decodeFromString(OAuthMetadata.serializer(), response.body()) - return listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) + val urls = listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) .map { URI(it).toServerBaseUrl() } .distinct() + logger.info( + "TLS trust: OAuth discovery succeeded (issuer=${metadata.issuer}, " + + "endpoints=${urls.joinToString()})" + ) + return urls } private suspend fun sendGetRequest(httpClient: HttpClient, url: String, errorPrefix: String = "Request failed"): HttpResponse { @@ -211,7 +227,12 @@ class OpenShiftAuthCodeFlow( *if (clientIdInForm) arrayOf("client_id" to clientId) else emptyArray() ) - val token = sendPostRequest(client, metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") + val token = try { + sendPostRequest(client, metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") + } catch (e: Exception) { + logger.error("TLS trust: token request to ${metadata.tokenEndpoint} failed", e) + throw e + } val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null return SSOToken(accessToken = token.accessToken, idToken = "", accountLabel = accountLabel, expiresAt = expiresAt) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 20a54a5d..b31ad8db 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.auth.tls +import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils @@ -29,13 +30,20 @@ class DefaultTlsTrustManager( private val persistentKeyStore: PersistentKeyStore ) : TlsTrustManager { + private val logger = thisLogger() + override suspend fun ensureTrusted( serverUrl: String, decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, certificateAuthority: CertificateSource?, + endpointKind: TlsEndpointKind, ): TlsContext { val serverUri = URI(serverUrl) + logger.info( + "TLS trust: probing ${endpointKind.label} at $serverUrl " + + "(wizard CA=${certificateAuthority != null}, kind=$endpointKind)" + ) val namedCluster = KubeConfigUtils.getClusterByServer( @@ -44,6 +52,7 @@ class DefaultTlsTrustManager( ) if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { + logger.warn("TLS trust: using insecure skip for $serverUrl (kubeconfig insecure-skip-tls-verify)") return SslContextFactory.insecure() } @@ -51,15 +60,26 @@ class DefaultTlsTrustManager( sessionTrustStore.get(serverUrl) if (trustedCerts.isNotEmpty()) { + logger.debug( + "TLS trust: trying ${trustedCerts.size} known certificate(s) for $serverUrl " + + "(session=${sessionTrustStore.get(serverUrl).size}, " + + "preconfigured=${trustedCerts.size - sessionTrustStore.get(serverUrl).size})" + ) try { val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) withContext(Dispatchers.IO) { TlsProbe.connect(serverUri, tlsContext.sslContext) } + logger.info("TLS trust: existing trust accepted for $serverUrl") return tlsContext } catch (e: SSLHandshakeException) { - // Certificate changed or invalid → continue to capture + logger.warn( + "TLS trust: handshake failed with known certificate(s) for $serverUrl; " + + "will prompt (${e.message})" + ) } + } else { + logger.info("TLS trust: no known certificate for $serverUrl; will capture server certificate") } val captureContext = SslContextFactory.captureOnly() @@ -68,6 +88,7 @@ class DefaultTlsTrustManager( withContext(Dispatchers.IO) { TlsProbe.connect(serverUri, captureContext.sslContext) } + logger.warn("TLS trust: probe unexpectedly succeeded without trust for $serverUrl") return captureContext // should not normally succeed } catch (e: SSLHandshakeException) { val chain = (captureContext.trustManager as? CapturingTrustManager) @@ -87,14 +108,26 @@ class DefaultTlsTrustManager( serverUrl = serverUrl, certificateChain = chain, fingerprintSha256 = sha256Fingerprint(trustAnchor), - problem = problem + problem = problem, + endpointKind = endpointKind, + ) + + logger.info( + "TLS trust: prompting user for ${endpointKind.label} at $serverUrl " + + "(problem=$problem, fingerprint=${info.fingerprintSha256})" ) val decision = decisionHandler(info) if (!decision.trusted) { + logger.info("TLS trust: user rejected certificate for $serverUrl") throw TlsTrustRejectedException() } + logger.info( + "TLS trust: user accepted certificate for $serverUrl " + + "(scope=${decision.scope}, endpoint=${endpointKind.label})" + ) + val keyStore = persistentKeyStore.loadOrCreate() val persistentAlias = hostAlias(serverUri.host) @@ -123,7 +156,12 @@ class DefaultTlsTrustManager( val finalCerts = (trustedCerts + trustAnchor) .distinctBy { it.serialNumber } - return SslContextFactory.fromTrustedCerts(finalCerts) + val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts) + withContext(Dispatchers.IO) { + TlsProbe.connect(serverUri, tlsContext.sslContext) + } + logger.info("TLS trust: verified connection to $serverUrl after user acceptance") + return tlsContext } } @@ -136,25 +174,57 @@ class DefaultTlsTrustManager( certificateAuthority: CertificateSource? = null, ): TlsContext { val apiBaseUrl = URI(apiServerUrl).toServerBaseUrl() + logger.info("TLS trust: establishing OpenShift TLS context for API $apiBaseUrl") - ensureTrusted(apiBaseUrl, decisionHandler, certificateAuthority) + ensureTrusted( + apiBaseUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.API_SERVER, + ) val apiTls = mergedContextFor(listOf(apiBaseUrl), certificateAuthority) - val oauthUrls = runCatching { + val oauthUrls = try { OpenShiftAuthCodeFlow.discoverOAuthEndpointBaseUrls( apiBaseUrl, apiTls.sslContext, ) - }.getOrDefault(emptyList()) + } catch (e: Exception) { + logger.error( + "TLS trust: failed to discover OAuth endpoints from $apiBaseUrl. " + + "Login may fail if the OAuth host uses a different certificate.", + e + ) + throw e + } + + if (oauthUrls.isEmpty()) { + logger.warn( + "TLS trust: OAuth discovery returned no endpoints for $apiBaseUrl. " + + "Only the API server certificate will be trusted." + ) + } else { + logger.info("TLS trust: discovered OAuth endpoint host(s): ${oauthUrls.joinToString()}") + } val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() for (url in allUrls) { if (url != apiBaseUrl) { - ensureTrusted(url, decisionHandler, certificateAuthority) + ensureTrusted( + url, + decisionHandler, + certificateAuthority, + TlsEndpointKind.OAUTH, + ) } } - return mergedContextFor(allUrls, certificateAuthority) + val merged = mergedContextFor(allUrls, certificateAuthority) + logger.info( + "TLS trust: OpenShift TLS context ready for ${allUrls.size} endpoint(s): " + + allUrls.joinToString() + ) + return merged } suspend fun mergedContextFor( diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsEndpointKind.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsEndpointKind.kt new file mode 100644 index 00000000..1846d7e7 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsEndpointKind.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +/** Identifies which cluster endpoint triggered a TLS trust prompt or handshake. */ +enum class TlsEndpointKind(val label: String) { + UNKNOWN("server"), + API_SERVER("OpenShift API server"), + OAUTH("OpenShift OAuth endpoint"), +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt index 5336bda8..70b1de55 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt @@ -17,5 +17,6 @@ data class TlsServerCertificateInfo( val serverUrl: String, val certificateChain: List, val fingerprintSha256: String, - val problem: TlsTrustProblem + val problem: TlsTrustProblem, + val endpointKind: TlsEndpointKind = TlsEndpointKind.UNKNOWN, ) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt index 047e490d..b11aed44 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt @@ -23,5 +23,6 @@ interface TlsTrustManager { serverUrl: String, decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, certificateAuthority: CertificateSource? = null, + endpointKind: TlsEndpointKind = TlsEndpointKind.UNKNOWN, ): TlsContext } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt index f08a56bd..8b649b13 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt @@ -22,7 +22,9 @@ import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.TopGap import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI +import com.redhat.devtools.gateway.auth.tls.TlsEndpointKind import java.awt.BorderLayout +import java.awt.Component import java.awt.Dimension import java.awt.FlowLayout import java.awt.event.ActionEvent @@ -38,9 +40,11 @@ import javax.swing.JPanel * @param certificateInfo PEM/text representation of the certificate. */ class TLSTrustDecisionHandler( + parent: Component, private val serverUrl: String, + private val endpointKind: TlsEndpointKind, private val certificateInfo: String -) : DialogWrapper(true) { +) : DialogWrapper(parent, true) { companion object { val PREFERRED_SIZE = Dimension(500, 400) @@ -55,7 +59,7 @@ class TLSTrustDecisionHandler( private set init { - title = "Untrusted TLS Certificate" + title = "Untrusted TLS Certificate — ${endpointKind.label}" init() } @@ -75,7 +79,7 @@ class TLSTrustDecisionHandler( isOpaque = false add(JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { isOpaque = false - add(JBLabel("The server at ")) + add(JBLabel("The ${endpointKind.label} at ")) add(HyperlinkLabel(serverUrl).apply { setHyperlinkTarget(serverUrl) }) add(JBTextArea("presents a TLS certificate that is not trusted.")) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt index 9b3b8bfd..ce36e676 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -13,18 +13,40 @@ package com.redhat.devtools.gateway.auth.tls.ui import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.tls.* +import java.awt.Component +import javax.swing.JPanel object UITlsDecisionAdapter { - suspend fun decide(info: TlsServerCertificateInfo): TlsTrustDecision { + private val logger = thisLogger() + + /** + * @param parent optional parent Component for the trust dialog; if null, the dialog is + * centered on screen (no parent). The caller is responsible for ensuring + * the value is safe to use on the EDT (e.g. captured on the EDT before a + * background thread runs). + */ + suspend fun decide(info: TlsServerCertificateInfo, parent: Component? = null): TlsTrustDecision { + val resolvedParent = parent + logger.info( + "TLS trust: showing trust dialog for ${info.endpointKind.label} at ${info.serverUrl} " + + "(parent=${resolvedParent?.javaClass?.simpleName ?: "none"})" + ) + lateinit var dialog: TLSTrustDecisionHandler + // invokeAndWait is required here: trust runs on a progress worker thread while the EDT + // is blocked by runProcessWithProgressSynchronously. invokeLater would queue the dialog + // on the EDT and never run it. ModalityState.any() allows the dialog above the progress UI. ApplicationManager.getApplication().invokeAndWait( { dialog = TLSTrustDecisionHandler( + parent = resolvedParent ?: JPanel(), serverUrl = info.serverUrl, - certificateInfo = PemUtils.toPem(info.certificateChain.first()) + endpointKind = info.endpointKind, + certificateInfo = PemUtils.toPem(info.certificateChain.first()), ) dialog.show() }, @@ -32,14 +54,20 @@ object UITlsDecisionAdapter { ) return when { - !dialog.isTrusted -> + !dialog.isTrusted -> { + logger.info("TLS trust: user cancelled dialog for ${info.serverUrl}") TlsTrustDecision.reject() + } - dialog.rememberDecision -> + dialog.rememberDecision -> { + logger.info("TLS trust: user chose permanent trust for ${info.serverUrl}") TlsTrustDecision.permanent() + } - else -> + else -> { + logger.info("TLS trust: user chose session-only trust for ${info.serverUrl}") TlsTrustDecision.sessionOnly() + } } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index cf371292..dfa37d89 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -27,3 +27,14 @@ fun Throwable.isCancellationException(): Boolean = (this is CancellationExceptio fun Throwable.isLoginUserCancelled(): Boolean = generateSequence(this) { it.cause }.any { it is SsoLoginException.Cancelled } + +fun Throwable.isTlsRelated(): Boolean = + generateSequence(this) { it.cause }.any { throwable -> + val message = throwable.message.orEmpty() + val className = throwable::class.java.name + className.contains("SSL", ignoreCase = true) || + className.contains("Tls", ignoreCase = true) || + message.contains("PKIX", ignoreCase = true) || + message.contains("certificate", ignoreCase = true) || + message.contains("handshake", ignoreCase = true) + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index e61d1c01..bf901321 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -45,6 +45,7 @@ import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu import com.redhat.devtools.gateway.view.ui.requestInitialFocus import com.redhat.devtools.gateway.util.isLoginUserCancelled +import com.redhat.devtools.gateway.util.isTlsRelated import com.redhat.devtools.gateway.util.stripScheme import kotlinx.coroutines.* import java.awt.event.ItemEvent @@ -391,62 +392,103 @@ class DevSpacesServerStepView( onDispose() var authResult: Result? = null + val certificateAuthority = resolveCertificateAuthority(tfCertAuthority.text) + if (certificateAuthority != null) { + thisLogger().info( + "TLS trust: wizard Certificate Authority provided " + + "(file=${certificateAuthority.isFilePath})" + ) + } - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - runBlocking { - val indicator = ProgressManager.getInstance().progressIndicator + var tlsContext: TlsContext? = null - try { + try { + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking { + val indicator = ProgressManager.getInstance().progressIndicator indicator.text = "Establishing secure connection..." - val certificateAuthority = resolveCertificateAuthority(tfCertAuthority.text) - val tlsContext = resolveTlsContext( + tlsContext = resolveTlsContext( server, strategy.getAuthMethod(), certificateAuthority, ) + } + }, + "Establishing secure connection...", + true, + null, + component + ) + } catch (e: ProcessCanceledException) { + return false + } catch (e: Exception) { + return handleConnectionFailure(server, e) + } - indicator.text = "Connecting to cluster..." - strategy.authenticate( - selectedCluster, - server, - tlsContext, - devSpacesContext, - indicator - ) - authResult = Result.success(Unit) - } catch (e: ProcessCanceledException) { - throw e - } catch (e: Exception) { - authResult = Result.failure(e) + val resolvedTlsContext = tlsContext + ?: return handleConnectionFailure(server, IllegalStateException("TLS context was not established")) + + try { + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking { + val indicator = ProgressManager.getInstance().progressIndicator + + try { + indicator.text = "Connecting to cluster..." + strategy.authenticate( + selectedCluster, + server, + resolvedTlsContext, + devSpacesContext, + indicator + ) + authResult = Result.success(Unit) + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Exception) { + authResult = Result.failure(e) + } } - } - }, - "Connecting to OpenShift...", - true, - null, - component - ) + }, + "Connecting to OpenShift...", + true, + null, + component + ) + } catch (e: ProcessCanceledException) { + return false + } - val result = authResult!! + val result = authResult + ?: return handleConnectionFailure(server, IllegalStateException("Authentication did not complete")) return result.fold( onSuccess = { settings.save(selectedCluster) true }, - onFailure = { e -> - thisLogger().warn(e) - if (!e.isLoginUserCancelled()) { - Dialogs.error( - "Could not connect to cluster ${server.stripScheme()}.\n\nReason: ${e.message ?: "Unknown error"}", - "Connection Failed" - ) - } - false - } + onFailure = { e -> handleConnectionFailure(server, e) } ) } + private fun handleConnectionFailure(server: String, e: Throwable): Boolean { + thisLogger().warn("Connection to $server failed", e) + if (!e.isLoginUserCancelled()) { + val reason = e.message ?: "Unknown error" + val tlsHint = if (e.isTlsRelated()) { + "\n\nTLS details were written to idea.log (search for \"TLS trust\")." + } else { + "" + } + Dialogs.error( + "Could not connect to cluster ${server.stripScheme()}.\n\nReason: $reason$tlsHint", + "Connection Failed" + ) + } + return false + } + private fun confirmAuthSwitchIfNeeded(): Boolean { val tokenPresent = findStrategy()?.tfToken?.password?.isNotEmpty() == true val certStrategy = findStrategy() @@ -516,12 +558,15 @@ class DevSpacesServerStepView( authMethod: AuthMethod, certificateAuthority: CertificateSource?, ): TlsContext { + val decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision = { info -> + UITlsDecisionAdapter.decide(info, component) + } return when (authMethod) { AuthMethod.OPENSHIFT, AuthMethod.OPENSHIFT_CREDENTIALS -> tlsTrustManager.ensureOpenShiftTlsContext( serverUrl, - UITlsDecisionAdapter::decide, + decisionHandler, certificateAuthority, ) else -> @@ -533,10 +578,14 @@ class DevSpacesServerStepView( serverUrl: String, certificateAuthority: CertificateSource?, ): TlsContext { + val decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision = { info -> + UITlsDecisionAdapter.decide(info, component) + } return tlsTrustManager.ensureTrusted( serverUrl, - UITlsDecisionAdapter::decide, + decisionHandler, certificateAuthority, + TlsEndpointKind.UNKNOWN, ) } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt new file mode 100644 index 00000000..ba9fffd0 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import javax.net.ssl.SSLHandshakeException + +class ExceptionUtilsTest { + + @Test + fun `#isTlsRelated detects PKIX errors`() { + val error = SSLHandshakeException( + "PKIX path building failed: unable to find valid certification path to requested target" + ) + + assertThat(error.isTlsRelated()).isTrue() + } + + @Test + fun `#isTlsRelated ignores unrelated errors`() { + assertThat(IllegalStateException("not authenticated").isTlsRelated()).isFalse() + } +} From 6ca74b7494efb9c8b0c12919a2b73df3f4f577a9 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 20:41:38 +0200 Subject: [PATCH 09/10] refactor: extracted OAuthDiscovery for better readable OpenShiftAuthCodeFlow class Signed-off-by: Andre Dietisheim --- .../gateway/auth/code/HttpClientExtensions.kt | 64 ++++++++++ .../gateway/auth/code/OAuthDiscovery.kt | 69 ++++++++++ .../auth/code/OpenShiftAuthCodeFlow.kt | 118 ++---------------- .../auth/tls/DefaultTlsTrustManager.kt | 7 +- .../gateway/auth/code/OAuthDiscoveryTest.kt | 104 +++++++++++++++ .../auth/code/OpenShiftAuthCodeFlowTest.kt | 118 ++++++++++++++++++ 6 files changed, 364 insertions(+), 116 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscoveryTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlowTest.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt new file mode 100644 index 00000000..6b36cf81 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import kotlinx.coroutines.future.await +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +data class AccessTokenResponseJson( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long +) + +suspend fun HttpClient.sendGetRequest( + url: String, + errorPrefix: String = "Request to $url failed" +): HttpResponse { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + val response = sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") + } + return response +} + +suspend fun HttpClient.sendPostRequest( + url: String, + authHeader: String, + formBody: String, + errorPrefix: String = "Request to $url failed" +): AccessTokenResponseJson { + val request = HttpRequest.newBuilder() + .uri(URI(url)) + .header("Authorization", authHeader) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build() + val response = sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") + } + return json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt new file mode 100644 index 00000000..6706f07e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.util.toServerBaseUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import javax.net.ssl.SSLContext + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +data class OAuthMetadata( + val issuer: String, + @SerialName("authorization_endpoint") + val authorizationEndpoint: String, + @SerialName("token_endpoint") + val tokenEndpoint: String +) + +class OAuthDiscovery( + apiServerUrl: String, + sslContext: SSLContext, + private val client: HttpClient = HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() +) { + + private val discoveryUrl = "$apiServerUrl/.well-known/oauth-authorization-server" + + suspend fun discoverOAuthMetadata(): OAuthMetadata { + val response = client.sendGetRequest(discoveryUrl) + return json.decodeFromString(OAuthMetadata.serializer(), response.body()) + } + + suspend fun endpointBaseUrls(): List { + thisLogger().info("TLS trust: discovering OAuth endpoints from $discoveryUrl") + val md = try { + discoverOAuthMetadata() + } catch (e: Exception) { + thisLogger().error("TLS trust: OAuth discovery request to $discoveryUrl failed", e) + throw e + } + val urls = listOf(md.tokenEndpoint, md.authorizationEndpoint) + .map { URI(it).toServerBaseUrl() } + .distinct() + thisLogger().info( + "TLS trust: OAuth discovery succeeded (issuer=${md.issuer}, " + + "endpoints=${urls.joinToString()})" + ) + return urls + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index 64fe7d43..ead18fbb 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -19,11 +19,8 @@ import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce import com.intellij.openapi.diagnostic.logger -import com.redhat.devtools.gateway.util.toServerBaseUrl +import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.future.await -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import java.lang.Void import java.net.URI import java.net.URLDecoder @@ -42,11 +39,10 @@ import javax.net.ssl.SSLContext class OpenShiftAuthCodeFlow( private val apiServerUrl: String, // Cluster API server private val redirectUri: URI?, // Local callback server URI (optional) - private val sslContext: SSLContext + private val sslContext: SSLContext, + private val discovery: OAuthDiscovery = OAuthDiscovery(apiServerUrl, sslContext), ) : AuthCodeFlow { - private val logger = logger() - private lateinit var codeVerifier: CodeVerifier private lateinit var state: State @@ -68,102 +64,8 @@ class OpenShiftAuthCodeFlow( .build() } - @Serializable - private data class OAuthMetadata( - val issuer: String, - @SerialName("authorization_endpoint") - val authorizationEndpoint: String, - @SerialName("token_endpoint") - val tokenEndpoint: String - ) - - companion object { - private val logger = logger() - private val json = Json { ignoreUnknownKeys = true } - - /** OAuth HTTP endpoint base URLs discovered from the API server. */ - suspend fun discoverOAuthEndpointBaseUrls( - apiServerUrl: String, - sslContext: SSLContext, - ): List { - val discoveryUrl = "$apiServerUrl/.well-known/oauth-authorization-server" - logger.info("TLS trust: discovering OAuth endpoints from $discoveryUrl") - val client = HttpClient.newBuilder() - .sslContext(sslContext) - .version(HttpClient.Version.HTTP_1_1) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - - val response = try { - sendGetRequest(client, discoveryUrl, "OAuth discovery failed") - } catch (e: Exception) { - logger.error("TLS trust: OAuth discovery request to $discoveryUrl failed", e) - throw e - } - val metadata = json.decodeFromString(OAuthMetadata.serializer(), response.body()) - val urls = listOf(metadata.tokenEndpoint, metadata.authorizationEndpoint) - .map { URI(it).toServerBaseUrl() } - .distinct() - logger.info( - "TLS trust: OAuth discovery succeeded (issuer=${metadata.issuer}, " + - "endpoints=${urls.joinToString()})" - ) - return urls - } - - private suspend fun sendGetRequest(httpClient: HttpClient, url: String, errorPrefix: String = "Request failed"): HttpResponse { - val request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build() - - val response = httpClient.sendAsync( - request, - HttpResponse.BodyHandlers.ofString() - ).await() - if (response.statusCode() !in 200..299) { - error("$errorPrefix: ${response.statusCode()}\n${response.body()}") - } - return response - } - - private suspend fun sendPostRequest( - httpClient: HttpClient, - url: String, - authHeader: String, - formBody: String, - errorPrefix: String = "Request failed" - ): AccessTokenResponseJson { - val request = HttpRequest.newBuilder() - .uri(URI(url)) - .header("Authorization", authHeader) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(formBody)) - .build() - - val response = httpClient.sendAsync( - request, - HttpResponse.BodyHandlers.ofString() - ).await() - if (response.statusCode() !in 200..299) { - error("$errorPrefix: ${response.statusCode()}\n${response.body()}") - } - - return json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) - } - } - - /** - * Discover OAuth endpoints from the cluster. - */ - private suspend fun discoverOAuthMetadata(): OAuthMetadata { - val response = sendGetRequest(discoveryClient, "$apiServerUrl/.well-known/oauth-authorization-server") - return json.decodeFromString(OAuthMetadata.serializer(), response.body()) - } - override suspend fun startAuthFlow(): AuthCodeRequest { - metadata = discoverOAuthMetadata() + metadata = discovery.discoverOAuthMetadata() codeVerifier = CodeVerifier() state = State() @@ -183,12 +85,6 @@ class OpenShiftAuthCodeFlow( ) } - @Serializable - data class AccessTokenResponseJson( - @SerialName("access_token") val accessToken: String, - @SerialName("expires_in") val expiresIn: Long - ) - override suspend fun handleCallback(parameters: Parameters): SSOToken { val code: String = parameters["code"] ?: error("Missing 'code' parameter in callback") val uri = redirectUri ?: error("redirectUri is required for code exchange") @@ -228,9 +124,9 @@ class OpenShiftAuthCodeFlow( ) val token = try { - sendPostRequest(client, metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") + client.sendPostRequest(metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") } catch (e: Exception) { - logger.error("TLS trust: token request to ${metadata.tokenEndpoint} failed", e) + thisLogger().error("TLS trust: token request to ${metadata.tokenEndpoint} failed", e) throw e } val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null @@ -241,7 +137,7 @@ class OpenShiftAuthCodeFlow( val username = parameters["username"] ?: error("Missing 'username'") val password = parameters["password"] ?: error("Missing 'password'") - metadata = discoverOAuthMetadata() + metadata = discovery.discoverOAuthMetadata() codeVerifier = CodeVerifier() state = State() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index b31ad8db..bc22d9c3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -12,7 +12,7 @@ package com.redhat.devtools.gateway.auth.tls import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.OAuthDiscovery import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.util.toServerBaseUrl @@ -185,10 +185,7 @@ class DefaultTlsTrustManager( val apiTls = mergedContextFor(listOf(apiBaseUrl), certificateAuthority) val oauthUrls = try { - OpenShiftAuthCodeFlow.discoverOAuthEndpointBaseUrls( - apiBaseUrl, - apiTls.sslContext, - ) + OAuthDiscovery(apiBaseUrl, apiTls.sslContext).endpointBaseUrls() } catch (e: Exception) { logger.error( "TLS trust: failed to discover OAuth endpoints from $apiBaseUrl. " + diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscoveryTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscoveryTest.kt new file mode 100644 index 00000000..5475654a --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscoveryTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture +import javax.net.ssl.SSLContext + +class OAuthDiscoveryTest { + + private val httpClient = mockk() + private val discovery = OAuthDiscovery( + apiServerUrl = "https://api.cluster.example.invalid:6443", + sslContext = mockk(relaxed = true), + client = httpClient + ) + + private val metadataJson = """ + { + "issuer": "https://api.cluster.example.invalid:6443", + "authorization_endpoint": "https://oauth-openshift.cluster.example.invalid:443/oauth/authorize", + "token_endpoint": "https://oauth-openshift.cluster.example.invalid:443/oauth/token" + } + """.trimIndent() + + private fun mockHttpResponse(statusCode: Int, body: String): HttpResponse { + val response = mockk>() + every { response.statusCode() } returns statusCode + every { response.body() } returns body + return response + } + + private fun stubSendAsync(response: HttpResponse) { + every { + httpClient.sendAsync(any(), any>()) + } returns CompletableFuture.completedFuture(response) + } + + @Test + fun `discoverOAuthMetadata returns metadata when response is valid`() = runTest { + stubSendAsync(mockHttpResponse(200, metadataJson)) + + val metadata = discovery.discoverOAuthMetadata() + + assertThat(metadata.issuer).isEqualTo("https://api.cluster.example.invalid:6443") + assertThat(metadata.authorizationEndpoint).isEqualTo("https://oauth-openshift.cluster.example.invalid:443/oauth/authorize") + assertThat(metadata.tokenEndpoint).isEqualTo("https://oauth-openshift.cluster.example.invalid:443/oauth/token") + } + + @Test + fun `discoverOAuthMetadata throws on HTTP error`() = runTest { + stubSendAsync(mockHttpResponse(404, "Not Found")) + + val result = kotlin.runCatching { discovery.discoverOAuthMetadata() } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("404") + .hasMessageContaining("Not Found") + } + + @Test + fun `endpointBaseUrls returns distinct base URLs when endpoints differ`() = runTest { + stubSendAsync(mockHttpResponse(200, metadataJson)) + + val urls = discovery.endpointBaseUrls() + + assertThat(urls).containsExactly("https://oauth-openshift.cluster.example.invalid:443") + } + + @Test + fun `endpointBaseUrls deduplicates when token and authorize endpoints share the same base`() = runTest { + val sameHostJson = """ + { + "issuer": "https://api.cluster.example.invalid:6443", + "authorization_endpoint": "https://oauth.cluster.example.invalid:443/oauth/authorize", + "token_endpoint": "https://oauth.cluster.example.invalid:443/oauth/token" + } + """.trimIndent() + stubSendAsync(mockHttpResponse(200, sameHostJson)) + + val urls = discovery.endpointBaseUrls() + + assertThat(urls).containsExactly("https://oauth.cluster.example.invalid:443") + } + + +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlowTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlowTest.kt new file mode 100644 index 00000000..8339ac86 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlowTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI +import javax.net.ssl.SSLContext + +class OpenShiftAuthCodeFlowTest { + + private val discovery = mockk() + + private val authCodeFlow = OpenShiftAuthCodeFlow( + apiServerUrl = "https://api.cluster.example.invalid:6443", + redirectUri = URI("http://localhost:12345/callback"), + sslContext = mockk(relaxed = true), + discovery = discovery + ) + + private val validMetadata = OAuthMetadata( + issuer = "https://api.cluster.example.invalid:6443", + authorizationEndpoint = "https://oauth-openshift.cluster.example.invalid:443/oauth/authorize", + tokenEndpoint = "https://oauth-openshift.cluster.example.invalid:443/oauth/token" + ) + + @Test + fun `startAuthFlow returns AuthCodeRequest when discovery succeeds`() = runTest { + coEvery { discovery.discoverOAuthMetadata() } returns validMetadata + + val request = authCodeFlow.startAuthFlow() + + assertThat(request.authorizationUri).isNotNull + assertThat(request.authorizationUri.toString()) + .startsWith("https://oauth-openshift.cluster.example.invalid:443/oauth/authorize") + assertThat(request.codeVerifier).isNotNull + assertThat(request.nonce).isNotNull + } + + @Test + fun `startAuthFlow propagates exception when discovery fails`() = runTest { + coEvery { discovery.discoverOAuthMetadata() } throws IllegalStateException("Discovery failed") + + val result = kotlin.runCatching { authCodeFlow.startAuthFlow() } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Discovery failed") + } + + @Test + fun `handleCallback throws when code parameter is missing`() = runTest { + val result = kotlin.runCatching { authCodeFlow.handleCallback(emptyMap()) } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Missing 'code' parameter in callback") + } + + @Test + fun `handleCallback throws when redirectUri is null`() = runTest { + val flowWithoutRedirect = OpenShiftAuthCodeFlow( + apiServerUrl = "https://api.cluster.example.invalid:6443", + redirectUri = null, + sslContext = mockk(relaxed = true), + discovery = discovery + ) + + val result = kotlin.runCatching { flowWithoutRedirect.handleCallback(mapOf("code" to "abc123")) } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("redirectUri is required for code exchange") + } + + @Test + fun `login propagates exception when discovery fails`() = runTest { + coEvery { discovery.discoverOAuthMetadata() } throws IllegalStateException("Discovery failed") + + val result = kotlin.runCatching { + authCodeFlow.login(mapOf("username" to "test", "password" to "pass")) + } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Discovery failed") + } + + @Test + fun `login throws when username is missing`() = runTest { + val result = kotlin.runCatching { authCodeFlow.login(mapOf("password" to "pass")) } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Missing 'username'") + } + + @Test + fun `login throws when password is missing`() = runTest { + val result = kotlin.runCatching { authCodeFlow.login(mapOf("username" to "test")) } + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()) + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Missing 'password'") + } +} From c0a8be006a7d2d1507aa79df6c095fc8ca3dd659 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 17 Jun 2026 21:55:18 +0200 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20improve=20TLS=20trust=20dialog=20U?= =?UTF-8?q?I=20=E2=80=94=20clickable=20URL,=20text=20wrapping,=20certifica?= =?UTF-8?q?te=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...onHandler.kt => TLSTrustDecisionDialog.kt} | 92 ++++++++++--------- .../auth/tls/ui/UITlsDecisionAdapter.kt | 14 +-- 2 files changed, 56 insertions(+), 50 deletions(-) rename src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/{TLSTrustDecisionHandler.kt => TLSTrustDecisionDialog.kt} (59%) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt similarity index 59% rename from src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt rename to src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt index 8b649b13..1fe38995 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt @@ -11,43 +11,42 @@ */ package com.redhat.devtools.gateway.auth.tls.ui +import com.intellij.ide.BrowserUtil import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.VerticalFlowLayout -import com.intellij.ui.HyperlinkLabel -import com.intellij.ui.components.JBLabel +import com.intellij.ui.HyperlinkAdapter import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextArea -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.auth.tls.TlsEndpointKind import java.awt.BorderLayout import java.awt.Component import java.awt.Dimension -import java.awt.FlowLayout import java.awt.event.ActionEvent import javax.swing.Action import javax.swing.BorderFactory import javax.swing.JComponent +import javax.swing.JEditorPane import javax.swing.JPanel +import javax.swing.UIManager +import javax.swing.event.HyperlinkEvent /** * Dialog that asks the user to trust a TLS certificate from a server. * + * @param parent the parent component for modality; if null, dialog is centered on screen. * @param serverUrl The URL of the server presenting the certificate. + * @param endpointKind The kind of TLS endpoint (server or client). * @param certificateInfo PEM/text representation of the certificate. */ -class TLSTrustDecisionHandler( - parent: Component, +class TLSTrustDecisionDialog( + parent: Component?, private val serverUrl: String, private val endpointKind: TlsEndpointKind, private val certificateInfo: String -) : DialogWrapper(parent, true) { +) : DialogWrapper(parent ?: JPanel(), true) { companion object { - val PREFERRED_SIZE = Dimension(500, 400) + val PREFERRED_SIZE = Dimension(600, 400) } /** Will be true if user chose to persist the trust decision. */ @@ -64,50 +63,57 @@ class TLSTrustDecisionHandler( } override fun createCenterPanel(): JComponent { - val panel = JPanel(BorderLayout(8, 8)) + val panel = JPanel(BorderLayout(16, 16)).apply { + border = JBUI.Borders.empty(JBUI.scale(8)) + } + + val wrappedUrl = serverUrl.chunked(40).joinToString("\u200B") + val htmlText = """ + + + + + + The ${endpointKind.label} at $wrappedUrl presents a TLS certificate that is not trusted. +
+ You can choose to trust it permanently, trust it for this session only, or cancel the connection. + + + """.trimIndent() - val message = panel { - row { - cell( - JPanel(VerticalFlowLayout( - VerticalFlowLayout.LEFT, - 0, - JBUI.scale(4), - true, - false - )).apply { - isOpaque = false - add(JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { - isOpaque = false - add(JBLabel("The ${endpointKind.label} at ")) - add(HyperlinkLabel(serverUrl).apply { setHyperlinkTarget(serverUrl) }) - add(JBTextArea("presents a TLS certificate that is not trusted.")) - } - ) - add( - JBTextArea( - "You can choose to trust it permanently, trust it for this session only, or cancel the connection." - ) - ) - } - ).align(AlignX.FILL) - }.topGap(TopGap.MEDIUM).bottomGap(BottomGap.MEDIUM) + val messagePane = object : JEditorPane("text/html", htmlText) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(PREFERRED_SIZE.width, size.height) + } + }.apply { + isEditable = false + isOpaque = false + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + font = UIManager.getFont("Label.font") + addHyperlinkListener(object : HyperlinkAdapter() { + override fun hyperlinkActivated(e: HyperlinkEvent) { + BrowserUtil.browse(e.url) + } + }) } + panel.add(messagePane, BorderLayout.NORTH) val certArea = JBTextArea(certificateInfo).apply { isEditable = false - lineWrap = false - //font = JBLabel().font + lineWrap = true + wrapStyleWord = true border = BorderFactory.createEmptyBorder() } val scrollPane = JBScrollPane(certArea).apply { preferredSize = PREFERRED_SIZE - setBorder(JBUI.Borders.empty()) + setBorder(null) setViewportBorder(null) } - panel.add(message, BorderLayout.NORTH) panel.add(scrollPane, BorderLayout.CENTER) return panel diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt index ce36e676..7de46fa0 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -14,9 +14,10 @@ package com.redhat.devtools.gateway.auth.tls.ui import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.auth.tls.* +import com.redhat.devtools.gateway.auth.tls.PemUtils +import com.redhat.devtools.gateway.auth.tls.TlsServerCertificateInfo +import com.redhat.devtools.gateway.auth.tls.TlsTrustDecision import java.awt.Component -import javax.swing.JPanel object UITlsDecisionAdapter { @@ -29,21 +30,20 @@ object UITlsDecisionAdapter { * background thread runs). */ suspend fun decide(info: TlsServerCertificateInfo, parent: Component? = null): TlsTrustDecision { - val resolvedParent = parent logger.info( "TLS trust: showing trust dialog for ${info.endpointKind.label} at ${info.serverUrl} " + - "(parent=${resolvedParent?.javaClass?.simpleName ?: "none"})" + "(parent=${parent?.javaClass?.simpleName ?: "none"})" ) - lateinit var dialog: TLSTrustDecisionHandler + lateinit var dialog: TLSTrustDecisionDialog // invokeAndWait is required here: trust runs on a progress worker thread while the EDT // is blocked by runProcessWithProgressSynchronously. invokeLater would queue the dialog // on the EDT and never run it. ModalityState.any() allows the dialog above the progress UI. ApplicationManager.getApplication().invokeAndWait( { - dialog = TLSTrustDecisionHandler( - parent = resolvedParent ?: JPanel(), + dialog = TLSTrustDecisionDialog( + parent = parent, serverUrl = info.serverUrl, endpointKind = info.endpointKind, certificateInfo = PemUtils.toPem(info.certificateChain.first()),