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/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 013b93e8..b199ee43 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.apiclient.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/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..80a3dc36 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt @@ -0,0 +1,68 @@ +/* + * 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.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 8293d4c2..289b480f 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,11 +18,9 @@ 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 kotlinx.coroutines.* +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 import java.net.URLEncoder @@ -40,7 +38,8 @@ 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 lateinit var codeVerifier: CodeVerifier @@ -48,8 +47,6 @@ class OpenShiftAuthCodeFlow( private lateinit var metadata: OAuthMetadata - private val json = Json { ignoreUnknownKeys = true } - private val discoveryClient: HttpClient by lazy { HttpClient.newBuilder() .sslContext(sslContext) @@ -66,38 +63,8 @@ class OpenShiftAuthCodeFlow( .build() } - @Serializable - private data class OAuthMetadata( - val issuer: String, - - @SerialName("authorization_endpoint") - val authorizationEndpoint: String, - - @SerialName("token_endpoint") - val tokenEndpoint: String - ) - - /** - * 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()}") - } - - return json.decodeFromString(OAuthMetadata.serializer(), response.body()) - } - override suspend fun startAuthFlow(): AuthCodeRequest { - metadata = discoverOAuthMetadata() + metadata = discovery.discoverOAuthMetadata() codeVerifier = CodeVerifier() state = State() @@ -117,16 +84,18 @@ 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") - - return exchangeCodeForToken(code) + val code: String = parameters["code"] + ?: error("Missing 'code' parameter in callback") + 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 = @@ -135,55 +104,59 @@ 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 token = requestToken(client, authHeader, form) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + return SSOToken(accessToken = token.accessToken, idToken = "", accountLabel = accountLabel, expiresAt = expiresAt) + } - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() !in 200..299) { - error("Token request failed: ${response.statusCode()}\n${response.body()}") + private suspend fun requestToken( + client: HttpClient, + authHeader: String, + form: String + ): AccessTokenResponseJson { + val token = try { + client.sendPostRequest(metadata.tokenEndpoint, authHeader, form, errorPrefix = "Token request failed") + } catch (e: Exception) { + thisLogger().error("TLS trust: token request to ${metadata.tokenEndpoint} failed", e) + throw e } - - 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 - ) + return token } override suspend fun login(parameters: Parameters): SSOToken { val username = parameters["username"] ?: error("Missing 'username'") val password = parameters["password"] ?: error("Missing 'password'") - metadata = discoverOAuthMetadata() + metadata = discovery.discoverOAuthMetadata() codeVerifier = CodeVerifier() state = State() - val httpClient = noRedirectClient - val redirectUri = URI( metadata.tokenEndpoint.replace( "/oauth/token", @@ -203,16 +176,36 @@ class OpenShiftAuthCodeFlow( val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) - // First request (expect 401) + val response = sendWithRetryOn401(noRedirectClient, authorizeUri, basicAuth) + val location = response.headers().firstValue("Location") + .orElseThrow { error("Missing redirect Location header") } + val params = parseRedirectQuery(location) + val code = params["code"] ?: error("Authorization code not found in redirect") + val token = exchangeCodeForToken(code, noRedirectClient, "openshift-challenging-client", redirectUri, clientIdInForm = false) + + return SSOToken( + accessToken = token.accessToken, + idToken = token.idToken, + accountLabel = username, + expiresAt = token.expiresAt + ) + } + + private suspend fun sendWithRetryOn401( + client: HttpClient, + authorizeUri: URI, + basicAuth: String + ): HttpResponse { var request = HttpRequest.newBuilder() .uri(authorizeUri) .header("X-Csrf-Token", "1") .GET() .build() - var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() + var response = client + .sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .await() - // Retry with Basic auth if (response.statusCode() == 401) { request = HttpRequest.newBuilder() .uri(authorizeUri) @@ -221,72 +214,13 @@ class OpenShiftAuthCodeFlow( .GET() .build() - response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() + response = client.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() } if (response.statusCode() !in listOf(302, 303)) { error("Authorization failed: ${response.statusCode()}") } - 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 code = params["code"] ?: error("Authorization code not found in redirect") - - val token = exchangeCodeForTokenWithBasicAuth(httpClient, code = code, redirectUri = redirectUri) - - return SSOToken( - accessToken = token.accessToken, - idToken = token.idToken, - accountLabel = username, - expiresAt = token.expiresAt - ) - } - - private suspend fun exchangeCodeForTokenWithBasicAuth( - httpClient: HttpClient, - code: String, - redirectUri: URI - ): SSOToken { - val clientAuth = "Basic " + Base64.getEncoder() - .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) - - val form = encodeForm( - "grant_type" to "authorization_code", - "code" to code, - "redirect_uri" to redirectUri.toString(), - "code_verifier" to codeVerifier.value - ) - - 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() - - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() != 200) { - error("Token exchange failed: ${response.statusCode()} ${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 = "", - expiresAt = expiresAt - ) + return response } } 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..64c0cece 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.apiclient.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/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 6b0a632b..e6fff794 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,127 +11,364 @@ */ package com.redhat.devtools.gateway.auth.tls +import com.intellij.openapi.diagnostic.thisLogger +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 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.SSLContext import javax.net.ssl.SSLHandshakeException class DefaultTlsTrustManager( private val kubeConfigProvider: suspend () -> List, private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List) -> Unit, private val sessionTrustStore: SessionTlsTrustStore, - private val persistentKeyStore: PersistentKeyStore + private val persistentKeyStore: PersistentKeyStore, + private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsProbe.connect(uri, ctx.sslContext) }, + private val oauthDiscovery: suspend (String, SSLContext) -> List = { apiBaseUrl, sslContext -> + OAuthDiscovery(apiBaseUrl, sslContext).endpointBaseUrls() + }, ) : TlsTrustManager { - override suspend fun ensureTrusted( + private val logger = thisLogger() + + override suspend fun createTlsContext( + serverUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource?, + endpointKind: TlsEndpointKind, + ): TlsContext = + establishTrustForEndpoint( + serverUrl, + decisionHandler, + certificateAuthority, + endpointKind, + ) + + private suspend fun establishTrustForEndpoint( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + 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 = - KubeConfigTlsUtils.findClusterByServer( - serverUrl, - kubeConfigProvider() - ) - - if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { + val namedCluster = KubeConfigUtils.getClusterByServer(serverUrl, kubeConfigProvider()) + if (checkForInsecureSkipTlsVerify(namedCluster, serverUrl)) { return SslContextFactory.insecure() } - val trustedCerts = mutableListOf() - namedCluster?.let { - trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) - } + val trustedCerts = resolveCertificatesForUrls(listOf(serverUrl), certificateAuthority) - trustedCerts += sessionTrustStore.get(serverUrl) + tryTrustedCertProbe(serverUrl, serverUri, trustedCerts)?.let { return it } - val keyStore = persistentKeyStore.loadOrCreate() - val persistentAlias = "host:${serverUri.host}" + val certInfo = captureServerCertificate(serverUri, trustedCerts) + ?: run { + logger.warn("TLS trust: probe unexpectedly succeeded without trust for $serverUrl") + return SslContextFactory.captureOnly() // should not normally succeed + } + + val info = TlsServerCertificateInfo( + serverUrl = serverUrl, + certificateChain = certInfo.chain, + fingerprintSha256 = sha256Fingerprint(certInfo.trustAnchor), + problem = certInfo.problem, + endpointKind = endpointKind, + ) + + logger.info("TLS trust: prompting user for ${endpointKind.label} at $serverUrl") - val persistentCert = keyStore.getCertificate(persistentAlias) - if (persistentCert is X509Certificate) { - trustedCerts += persistentCert + return persistAndVerifyAcceptedTrust( + serverUrl = serverUrl, + trustedCerts = trustedCerts, + namedCluster = namedCluster, + serverUri = serverUri, + certInfo = info, + decisionHandler = decisionHandler, + ) + } + + private fun checkForInsecureSkipTlsVerify( + namedCluster: KubeConfigNamedCluster?, + serverUrl: String, + ): Boolean { + if (namedCluster?.isSkipTlsVerify() == true) { + logger.warn("TLS trust: using insecure skip for $serverUrl (kubeconfig insecure-skip-tls-verify)") + return true } + return false + } - if (trustedCerts.isNotEmpty()) { - try { - val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) - withContext(Dispatchers.IO) { - TlsProbe.connect(serverUri, tlsContext.sslContext) - } - return tlsContext - } catch (e: SSLHandshakeException) { - // Certificate changed or invalid → continue to capture - } + private suspend fun tryTrustedCertProbe( + serverUrl: String, + serverUri: URI, + trustedCerts: List, + ): TlsContext? { + if (trustedCerts.isEmpty()) { + logger.info("TLS trust: no known certificate for $serverUrl; will capture server certificate") + return null } - val captureContext = SslContextFactory.captureOnly() + 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 { - withContext(Dispatchers.IO) { - TlsProbe.connect(serverUri, captureContext.sslContext) - } - return captureContext // should not normally succeed + return try { + val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) + withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + logger.info("TLS trust: existing trust accepted for $serverUrl") + tlsContext + } catch (e: SSLHandshakeException) { + logger.warn( + "TLS trust: handshake failed with known certificate(s) for $serverUrl; will prompt (${e.message})" + ) + null + } + } + + private data class CapturedCertInfo( + val problem: TlsTrustProblem, + val chain: List, + val trustAnchor: X509Certificate, + ) + + private suspend fun captureServerCertificate( + serverUri: URI, + trustedCerts: List, + ): CapturedCertInfo? { + val captureContext = SslContextFactory.captureOnly() + return try { + withContext(Dispatchers.IO) { tlsProbe(serverUri, captureContext) } + null // probe succeeded without throwing — no cert info, caller logs unexpected success } catch (e: SSLHandshakeException) { val chain = (captureContext.trustManager as? CapturingTrustManager) - ?.serverCertificateChain - ?.toList() - ?: throw e + ?.serverCertificateChain?.toList() ?: throw e val trustAnchor = chain.first() - - val problem = - if (trustedCerts.isEmpty()) - TlsTrustProblem.UNTRUSTED_CERTIFICATE - else - TlsTrustProblem.CERTIFICATE_CHANGED - - val info = TlsServerCertificateInfo( - serverUrl = serverUrl, - certificateChain = chain, - fingerprintSha256 = sha256Fingerprint(trustAnchor), - problem = problem + CapturedCertInfo( + problem = if (trustedCerts.isEmpty()) TlsTrustProblem.UNTRUSTED_CERTIFICATE + else TlsTrustProblem.CERTIFICATE_CHANGED, + chain = chain, + trustAnchor = trustAnchor, ) + } + } - val decision = decisionHandler(info) - if (!decision.trusted) { - throw TlsTrustRejectedException() - } + private suspend fun persistAndVerifyAcceptedTrust( + serverUrl: String, + trustedCerts: List, + namedCluster: KubeConfigNamedCluster?, + serverUri: URI, + certInfo: TlsServerCertificateInfo, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + ): TlsContext { + logger.info( + "TLS trust: prompting user for ${certInfo.endpointKind.label} at $serverUrl " + + "(problem=${certInfo.problem}, fingerprint=${certInfo.fingerprintSha256})" + ) - when (decision.scope) { - TlsTrustScope.SESSION_ONLY -> { - sessionTrustStore.put(serverUrl, listOf(trustAnchor)) - } + val decision = decisionHandler(certInfo) + if (!decision.trusted) { + logger.info("TLS trust: user rejected certificate for $serverUrl") + throw TlsTrustRejectedException() + } - TlsTrustScope.PERMANENT -> { - sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + logger.info( + "TLS trust: user accepted certificate for $serverUrl " + + "(scope=${decision.scope}, endpoint=${certInfo.endpointKind.label})" + ) - if (namedCluster != null) { - kubeConfigWriter(namedCluster, listOf(trustAnchor)) - } - KeyStoreUtils.addCertificate( - keyStore, - persistentAlias, - trustAnchor - ) - persistentKeyStore.save(keyStore) + val trustAnchor = certInfo.certificateChain.first() + val scope = decision.scope ?: error("Trusted decision without scope") + persistAcceptedTrust( + serverUrl = serverUrl, + host = serverUri.host, + trustAnchor = trustAnchor, + namedCluster = namedCluster, + scope = scope, + ) + + val finalCerts = (trustedCerts + trustAnchor).distinctBy { it.serialNumber } + val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts) + withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + logger.info("TLS trust: verified connection to $serverUrl after user acceptance") + return tlsContext + } + + private suspend fun persistAcceptedTrust( + serverUrl: String, + host: String, + trustAnchor: X509Certificate, + namedCluster: KubeConfigNamedCluster?, + scope: TlsTrustScope, + ) { + when (scope) { + TlsTrustScope.SESSION_ONLY -> { + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + } + + TlsTrustScope.PERMANENT -> { + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + + if (namedCluster != null) { + kubeConfigWriter(namedCluster, listOf(trustAnchor)) } - null -> error("Trusted decision without scope") + val keyStore = persistentKeyStore.loadOrCreate() + KeyStoreUtils.addCertificate( + keyStore, + hostAlias(host), + trustAnchor, + ) + persistentKeyStore.save(keyStore) } + } + } + + /** + * Returns a TLS context for the given OpenShift API server URL. + * + * If the kubeconfig indicates insecureSkipTlsVerify for the cluster, an insecure SSL context is returned. + * Otherwise, the method ensures the API server and its OAuth URLs are trusted based on the provided decision + * handler and optional certificate authority, and returns a merged TLS context. + * + * @param apiServerUrl The URL of the OpenShift API server. + * @param decisionHandler A suspending function that evaluates TLS certificate information and returns a trust decision. + * @param certificateAuthority An optional certificate source used as a trusted certificate authority. + * @return The TLS context configured for the API server and OAuth endpoints. + */ + override suspend fun createOpenShiftTlsContext( + apiServerUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource?, + ): TlsContext { + val apiBaseUrl = URI(apiServerUrl).toServerBaseUrl() + logger.info("TLS trust: establishing OpenShift TLS context for API $apiBaseUrl") + + + val apiTlsContext = establishTrustForEndpoint( + apiBaseUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.API_SERVER, + ) + + if (apiTlsContext.isInsecure) { + return apiTlsContext + } + + val oauthUrls = getOAuthUrls(apiBaseUrl, apiTlsContext) + + for (oauthUrl in oauthUrls) { + establishTrustForEndpoint( + oauthUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.OAUTH, + ) + } - val finalCerts = (trustedCerts + trustAnchor) - .distinctBy { it.serialNumber } + val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() - return SslContextFactory.fromTrustedCerts(finalCerts) + val merged = mergeTrustedContext(allUrls, certificateAuthority) + logger.info( + "TLS trust: OpenShift TLS context ready for ${allUrls.size} endpoint(s): " + + allUrls.joinToString() + ) + return merged + } + + private suspend fun getOAuthUrls(apiBaseUrl: String, apiTlsContext: TlsContext): List { + val oauthUrls = oauthDiscovery(apiBaseUrl, apiTlsContext.sslContext) + + 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()}") } + return oauthUrls } - /** Private helper: SHA-256 fingerprint of a certificate */ + internal suspend fun mergeTrustedContext( + serverUrls: Collection, + certificateAuthority: CertificateSource?, + ): TlsContext { + val certs = resolveCertificatesForUrls(serverUrls, certificateAuthority) + require(certs.isNotEmpty()) { + "No trusted certificates for: ${serverUrls.distinct().joinToString()}" + } + return SslContextFactory.fromTrustedCerts(certs) + } + + + /** + * Resolves trusted X.509 certificates for a collection of server URLs by merging: + * - CA certificates from an optional [certificateAuthority] + * - CA certificates from each named cluster in kubeconfig (one per URL) + * - Persistent certificate for each host from the persistent keystore, when no session certs exist + * - Session certificates (both per-URL and all-sessions) regardless of whether session certs exist + * + *

Session trust (from TLS wizard) takes precedence over stale kubeconfig or persistent store entries. + * If session certificates are present, they override the fall-through to CA/config/persistent-store.

+ * + * @param serverUrls The URLs to resolve certificates for. If empty, returns an empty list. + * @param certificateAuthority Optional CA to add as trusted certificates (added once, not per-URL). + * @return List of X.509 certificates to trust for all given server URLs, deduplicated by serial number. + */ + private suspend fun resolveCertificatesForUrls( + serverUrls: Collection, + certificateAuthority: CertificateSource?, + ): List { + if (serverUrls.isEmpty()) return emptyList() + + val configs = kubeConfigProvider() + val keyStore = persistentKeyStore.loadOrCreate() + val sessionCerts = sessionTrustStore.allCertificates() + val noSessionTrust = sessionCerts.isEmpty() + + return buildList { + if (noSessionTrust) { + certificateAuthority?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + } else { + addAll(sessionCerts) + } + + for (serverUrl in serverUrls.distinct()) { + addAll(sessionTrustStore.get(serverUrl)) + + if (noSessionTrust) { + KubeConfigUtils.getClusterByServer(serverUrl, configs)?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + + val persistentCert = keyStore.getCertificate(hostAlias(URI(serverUrl).host)) + if (persistentCert is X509Certificate) { + add(persistentCert) + } + } + } + }.distinctBy { it.serialNumber } + } + + private fun hostAlias(host: String) = "host:$host" + 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 8d31a407..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 @@ -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/ @@ -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,23 +20,22 @@ 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 { - 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) + ): List = + namedCluster.cluster.certificateAuthority + ?.let(::extractCaCertificates) + .orEmpty() + + fun extractCaCertificates(caSource: CertificateSource): List { + 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") 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/SslContextFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt index a72c2c6a..94e0e080 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt @@ -36,7 +36,7 @@ object SslContextFactory { init(null, arrayOf(trustAll), SecureRandom()) } - return TlsContext(sslContext, trustAll) + return TlsContext(sslContext, trustAll, isInsecure = true) } fun fromTrustedCerts(certs: List): TlsContext { @@ -77,7 +77,7 @@ object SslContextFactory { ) } - return TlsContext(sslContext, capturingTrustManager) + return TlsContext(sslContext, capturingTrustManager, isCapturingProbe = true) } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt index eab5f01c..0ba4c755 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt @@ -16,5 +16,7 @@ import javax.net.ssl.X509TrustManager data class TlsContext( val sslContext: SSLContext, - val trustManager: X509TrustManager + val trustManager: X509TrustManager, + val isInsecure: Boolean = false, + val isCapturingProbe: Boolean = false, ) \ No newline at end of file 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 1a13d5bb..84e5a419 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 @@ -19,8 +19,22 @@ interface TlsTrustManager { * @throws TlsTrustRejectedException if the user rejects the certificate * @throws javax.net.ssl.SSLHandshakeException if TLS ultimately fails */ - suspend fun ensureTrusted( + suspend fun createTlsContext( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, + endpointKind: TlsEndpointKind = TlsEndpointKind.UNKNOWN, + ): TlsContext + + /** + * Ensures the OpenShift API server and its OAuth endpoints are trusted. + * + * @throws TlsTrustRejectedException if the user rejects a certificate + * @throws javax.net.ssl.SSLHandshakeException if TLS ultimately fails + */ + suspend fun createOpenShiftTlsContext( + apiServerUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, ): TlsContext } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt new file mode 100644 index 00000000..bc92bff4 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025-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.ui + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.HyperlinkAdapter +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +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.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 TLSTrustDecisionDialog( + parent: Component?, + private val serverUrl: String, + private val endpointKind: TlsEndpointKind, + private val certificateInfo: String +) : DialogWrapper( + parent ?: JPanel(), + parent != null, +) { + + companion object { + val PREFERRED_SIZE = Dimension(600, 400) + } + + /** Will be true if user chose to persist the trust decision. */ + var rememberDecision: Boolean = false + private set + + /** Will be true if user trusted the certificate (permanent or session). */ + var isTrusted: Boolean = false + private set + + init { + title = "Untrusted TLS Certificate — ${endpointKind.label}" + init() + } + + override fun createCenterPanel(): JComponent { + 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 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 = true + wrapStyleWord = true + border = BorderFactory.createEmptyBorder() + } + + val scrollPane = JBScrollPane(certArea).apply { + preferredSize = PREFERRED_SIZE + setBorder(null) + setViewportBorder(null) + } + + panel.add(scrollPane, BorderLayout.CENTER) + + return panel + } + + override fun createActions(): Array { + return arrayOf( + object : DialogWrapperAction("Trust Permanently") { + override fun doAction(e: ActionEvent) { + isTrusted = true + rememberDecision = true + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Trust for This Session Only") { + override fun doAction(e: ActionEvent) { + isTrusted = true + rememberDecision = false + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Cancel") { + override fun doAction(e: ActionEvent) { + isTrusted = false + rememberDecision = false + close(CANCEL_EXIT_CODE) + } + } + ) + } +} 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 deleted file mode 100644 index c59a0e6f..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright (c) 2025-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.ui - -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.components.JBTextArea -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.event.ActionEvent -import javax.swing.Action -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. - * - * @param serverUrl The URL of the server presenting the certificate. - * @param certificateInfo PEM/text representation of the certificate. - */ -class TLSTrustDecisionHandler( - private val serverUrl: String, - private val certificateInfo: String -) : DialogWrapper(true) { - - /** Will be true if user chose to persist the trust decision. */ - var rememberDecision: Boolean = false - private set - - /** Will be true if user trusted the certificate (permanent or session). */ - var isTrusted: Boolean = false - private set - - init { - title = "Untrusted TLS Certificate" - init() - } - - 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 certArea = JBTextArea(certificateInfo).apply { - isEditable = false - lineWrap = false - font = message.font - } - - val scrollPane = JScrollPane(certArea).apply { - preferredSize = Dimension(600, 200) - } - - panel.add(message, BorderLayout.NORTH) - panel.add(scrollPane, BorderLayout.CENTER) - - return panel - } - - override fun createActions(): Array { - return arrayOf( - object : DialogWrapperAction("Trust Permanently") { - override fun doAction(e: ActionEvent) { - isTrusted = true - rememberDecision = true - close(OK_EXIT_CODE) - } - }, - object : DialogWrapperAction("Trust for This Session Only") { - override fun doAction(e: ActionEvent) { - isTrusted = true - rememberDecision = false - close(OK_EXIT_CODE) - } - }, - object : DialogWrapperAction("Cancel") { - override fun doAction(e: ActionEvent) { - isTrusted = false - rememberDecision = false - close(CANCEL_EXIT_CODE) - } - } - ) - } -} 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 new file mode 100644 index 00000000..d5b05b9d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025-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.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.PemUtils +import com.redhat.devtools.gateway.auth.tls.TlsServerCertificateInfo +import com.redhat.devtools.gateway.auth.tls.TlsTrustDecision +import java.awt.Component +import javax.swing.SwingUtilities + +object UITlsDecisionAdapter { + + 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 dialogParent = parent?.let { SwingUtilities.getWindowAncestor(it) } + logger.info( + "TLS trust: showing trust dialog for ${info.endpointKind.label} at ${info.serverUrl} " + + "(parent=${parent?.javaClass?.simpleName ?: "none"}, " + + "window=${dialogParent?.javaClass?.simpleName ?: "none"})" + ) + + lateinit var dialog: TLSTrustDecisionDialog + + ApplicationManager.getApplication().invokeAndWait( + { + dialog = TLSTrustDecisionDialog( + parent = dialogParent, + serverUrl = info.serverUrl, + endpointKind = info.endpointKind, + certificateInfo = PemUtils.toPem(info.certificateChain.first()), + ) + dialog.show() + }, + ModalityState.any(), + ) + + logger.info("TLS trust: trust dialog closed for ${info.serverUrl}") + + return when { + !dialog.isTrusted -> { + logger.info("TLS trust: user cancelled dialog for ${info.serverUrl}") + TlsTrustDecision.reject() + } + + dialog.rememberDecision -> { + logger.info("TLS trust: user chose permanent trust for ${info.serverUrl}") + TlsTrustDecision.permanent() + } + + 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/auth/tls/ui/UiTlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt deleted file mode 100644 index 0298bd01..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2025-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.ui - -import com.intellij.openapi.application.ApplicationManager -import com.redhat.devtools.gateway.auth.tls.* - -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() - } - - return when { - !dialog.isTrusted -> - TlsTrustDecision.reject() - - dialog.rememberDecision -> - TlsTrustDecision.permanent() - - else -> - TlsTrustDecision.sessionOnly() - } - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt index 6cb3a878..5feb43a6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/BlockStyleFilePersister.kt @@ -18,9 +18,17 @@ import com.redhat.devtools.gateway.openshift.mapOfNotNull import io.kubernetes.client.persister.ConfigPersister import org.yaml.snakeyaml.DumperOptions import java.io.File +import java.util.concurrent.ConcurrentHashMap class BlockStyleFilePersister(private val file: File) : ConfigPersister { + companion object { + private val fileLocks = ConcurrentHashMap() + + private fun lockFor(file: File): Any = + fileLocks.getOrPut(file.canonicalPath) { Any() } + } + @Throws(java.io.IOException::class) override fun save( contexts: ArrayList?, @@ -39,7 +47,7 @@ class BlockStyleFilePersister(private val file: File) : ConfigPersister { "users" to users, ) - synchronized(file) { + synchronized(lockFor(file)) { val options = DumperOptions().apply { width = 0 } val yamlFactory = YAMLFactory.builder() .dumperOptions(options) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt index 79c40794..58929dd9 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -26,6 +26,8 @@ data class KubeConfigNamedCluster( val name: String = toName(cluster) ) { + fun isSkipTlsVerify(): Boolean = cluster.isSkipTlsVerify() + companion object { fun fromMap(map: Map<*,*>): KubeConfigNamedCluster? { val name = map["name"] as? String ?: return null @@ -92,6 +94,8 @@ data class KubeConfigCluster( insecureSkipTlsVerify?.let { map["insecure-skip-tls-verify"] = it } return map } + + fun isSkipTlsVerify(): Boolean = insecureSkipTlsVerify == true } data class KubeConfigNamedContext( 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..cfcae263 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -14,6 +14,7 @@ package com.redhat.devtools.gateway.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.EnvironmentUtil import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.util.toServerBaseUrl import io.kubernetes.client.util.KubeConfig import java.io.File import java.net.URI @@ -34,6 +35,20 @@ object KubeConfigUtils { return currentUser?.user?.token != null } + fun getClusterByServer( + serverUrl: String, + kubeConfigs: List + ): KubeConfigNamedCluster? { + val baseUrl = runCatching { URI(serverUrl).toServerBaseUrl() }.getOrNull() + return kubeConfigs + .flatMap { it.clusters ?: emptyList() } + .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } + .firstOrNull { cluster -> + cluster.cluster.server == serverUrl || + (baseUrl != null && cluster.cluster.server == baseUrl) + } + } + fun getClusters(kubeconfigPaths: List): List { logger.info("Getting clusters from kubeconfig paths: $kubeconfigPaths") val kubeConfigs = toKubeConfigs(kubeconfigPaths) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt index 1c2a026b..9036549c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.openshift import com.intellij.openapi.diagnostic.logger import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.openshift.apiclient.ApiClientUtils import io.kubernetes.client.PortForward import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException 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 e1e0092c..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ /dev/null @@ -1,321 +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 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.TrustManagerFactory -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 - - 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 - - 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 client - } - - private fun createTrustManager( - certificateAuthority: CertificateSource?, - tlsContext: TlsContext - ): X509TrustManager = if (certificateAuthority != null) { - createTrustManager(certificateAuthority) - } else { - tlsContext.trustManager - } - - 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 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 - ): 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/ApiClientUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ApiClientUtils.kt similarity index 98% rename from src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtils.kt rename to src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ApiClientUtils.kt index 51a3f24f..f8dc8d4a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ApiClientUtils.kt @@ -9,7 +9,7 @@ * Contributors: * Red Hat, Inc. - initial API and implementation */ -package com.redhat.devtools.gateway.openshift +package com.redhat.devtools.gateway.openshift.apiclient import com.intellij.openapi.diagnostic.thisLogger import io.kubernetes.client.openapi.ApiClient diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/BaseClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/BaseClientBuilder.kt new file mode 100644 index 00000000..f880fa55 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/BaseClientBuilder.kt @@ -0,0 +1,45 @@ +/* + * 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.apiclient + +import io.kubernetes.client.openapi.ApiClient +import java.util.concurrent.TimeUnit + +/** + * 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 + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/DefaultClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/DefaultClientBuilder.kt new file mode 100644 index 00000000..d76de9ca --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/DefaultClientBuilder.kt @@ -0,0 +1,51 @@ +/* + * 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.apiclient + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.openapi.ApiClient + +/** + * 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() + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TlsClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TlsClientBuilder.kt new file mode 100644 index 00000000..97dcdfed --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TlsClientBuilder.kt @@ -0,0 +1,168 @@ +/* + * 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.apiclient + +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 io.kubernetes.client.openapi.ApiClient +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 + +const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L + +private fun normalizeBasePath(server: String): String = server.trim().removeSuffix("/") + +/** + * 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) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt new file mode 100644 index 00000000..fb06efa7 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt @@ -0,0 +1,103 @@ +/* + * 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.apiclient + +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.util.Config +import io.kubernetes.client.util.KubeConfig + +private const val userName = "openshift_user" +private const val contextName = "openshift_context" +private const val clusterName = "openshift_cluster" + +/** + * 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(null, token.toCharArray()) + val client = Config.fromConfig(kubeConfig) + return applyReadTimeout(client) + } + + private fun createKubeConfig( + certificateAuthority: CertificateSource?, + token: CharArray? + ): KubeConfig { + val usingToken = token?.isNotEmpty() == true + val usingClientCert = false + + require(usingToken) { + "Provide either token OR clientCert + clientKey." + } + + val clusterEntry = createCluster(certificateAuthority) + val userEntry = createUser(usingToken, token) + 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( + 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?): Map { + val user = mutableMapOf() + + if (usingToken && token != null) { + setToken(token, user) + } + + return mapOf( + "name" to userName, + "user" to user + ) + } + + private fun setToken(token: CharArray, user: MutableMap) { + user["token"] = String(token).trim() + } +} 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/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/DevSpacesWizardView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt index d8de29d9..5fb6bed2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt @@ -15,6 +15,7 @@ import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.view.steps.DevSpacesWorkspacesStepView import com.redhat.devtools.gateway.view.steps.DevSpacesServerStepView import com.redhat.devtools.gateway.view.steps.DevSpacesWizardStep +import com.redhat.devtools.gateway.view.steps.WizardAsyncWork import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager @@ -35,8 +36,8 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane private var nextButton = JButton() init { - steps.add(DevSpacesServerStepView(devSpacesContext, { enableNextButton() }) { nextStep() }) - steps.add(DevSpacesWorkspacesStepView(devSpacesContext) { enableNextButton() }.also { + steps.add(DevSpacesServerStepView(devSpacesContext, { enableNavigationButtons() }) { nextStep() }) + steps.add(DevSpacesWorkspacesStepView(devSpacesContext) { enableNavigationButtons() }.also { Disposer.register(this, it) }) @@ -73,10 +74,24 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } private fun nextStep() { - if (steps[currentStep].onNext()) applyStep(+1) + val step = steps[currentStep] + step.startAsyncNext()?.let { work -> + enableNavigationButtons(false) + WizardAsyncWork.execute("Connecting to OpenShift...", work) { advance -> + enableNavigationButtons(true) + if (advance) { + applyStep(+1) + } + } + return + } + if (step.onNext()) { + applyStep(+1) + } } private fun previousStep() { + WizardAsyncWork.invalidatePending() if (!steps[currentStep].onPrevious()) return if (isFirstStep()) { @@ -98,12 +113,18 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane onInit() } - enableNextButton() + enableNavigationButtons() + } + + private fun enableNavigationButtons() { + val step = steps[currentStep] + enableNavigationButtons( step.isNavigationEnabled()) } - private fun enableNextButton() { + private fun enableNavigationButtons(enabled: Boolean) { val step = steps[currentStep] - nextButton.isEnabled = step.isNextEnabled() + nextButton.isEnabled = enabled && step.isNextEnabled() + previousButton.isEnabled = enabled } private fun isFirstStep(): Boolean { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt index 964e7078..cc5e6faa 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt @@ -15,6 +15,7 @@ import com.intellij.openapi.ui.DialogWrapper import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.view.steps.DevSpacesServerStepView +import com.redhat.devtools.gateway.view.steps.WizardAsyncWork import java.awt.BorderLayout import java.awt.Dimension import javax.swing.JComponent @@ -51,12 +52,21 @@ class SelectClusterDialog( } override fun doOKAction() { - if (stepView.onNext()) { - super.doOKAction() + if (!isOKActionEnabled) return + + stepView.startAsyncNext()?.let { work -> + WizardAsyncWork.invalidatePending() + isOKActionEnabled = false + WizardAsyncWork.execute("Connecting to OpenShift...", work) { success -> + isOKActionEnabled = true + if (success) close(OK_EXIT_CODE) + } + return } } override fun doCancelAction() { + WizardAsyncWork.invalidatePending() stepView.onDispose() super.doCancelAction() } @@ -64,4 +74,4 @@ class SelectClusterDialog( fun showAndConnect(): Boolean { return showAndGet() } -} \ No newline at end of file +} 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..3fc75ccd 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,8 +16,9 @@ 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.progress.runBlockingCancellable import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.redhat.devtools.gateway.auth.tls.browseCertificate @@ -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 @@ -44,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 @@ -71,6 +73,13 @@ class DevSpacesServerStepView( private val settings: ServerSettings = ServerSettings() + @Volatile + private var connectInProgress = false + + /** Snapshot of [saveConfig] captured on the EDT when connect starts. */ + @Volatile + private var saveConfigForConnect = false + private lateinit var kubeconfigScope: CoroutineScope private lateinit var kubeconfigMonitor: KubeConfigMonitor @@ -116,8 +125,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 +136,7 @@ class DevSpacesServerStepView( OpenShiftOAuthAuthenticationStrategy( tfServer, ::saveKubeconfig, - setTokenDisplay + setTokenDisplay, ), ClientCertificateAuthenticationStrategy( tfServer, @@ -140,7 +149,7 @@ class DevSpacesServerStepView( ::saveKubeconfig, ::onFieldChanged, ::createEnterKeyListener, - setTokenDisplay + setTokenDisplay, ), RedHatSSOAuthenticationStrategy( tfServer, @@ -380,64 +389,80 @@ class DevSpacesServerStepView( return true } - override fun onNext(): Boolean { - val selectedCluster = getSelectedCluster() ?: return false + override fun onNext(): Boolean = false + + override fun isNavigationEnabled(): Boolean = !connectInProgress + + override fun startAsyncNext(): WizardAsyncWork? { + val selectedCluster = getSelectedCluster() ?: return null val server = selectedCluster.url - val strategy = currentStrategy ?: return false + val strategy = currentStrategy ?: return null - if (!confirmAuthSwitchIfNeeded()) return false + if (!confirmAuthSwitchIfNeeded()) return null onDispose() - var authResult: Result? = null - - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - runBlocking { - val indicator = ProgressManager.getInstance().progressIndicator - indicator.text = "Connecting to cluster..." - - try { - val tlsContext = resolveSslContext(server) - val certAuthorityData = tfCertAuthority.text.ifBlank { null } - - strategy.authenticate( - selectedCluster, - server, - certAuthorityData, - tlsContext, - devSpacesContext, - indicator - ) - authResult = Result.success(Unit) - } catch (e: Exception) { - authResult = Result.failure(e) - } + val certificateAuthority = resolveCertificateAuthority(tfCertAuthority.text) + if (certificateAuthority != null) { + thisLogger().info( + "TLS trust: wizard Certificate Authority provided " + + "(file=${certificateAuthority.isFilePath})" + ) + } + + saveConfigForConnect = saveConfig + + return WizardAsyncWork { indicator, onFinished -> + connectInProgress = true + try { + indicator.isIndeterminate = true + indicator.text = "Establishing secure connection..." + val tlsContext = runBlockingCancellable { + resolveTlsContext( + server, + strategy.getAuthMethod(), + certificateAuthority, + ) } - }, - "Connecting to OpenShift...", - true, - null, - component - ) - val result = authResult!! - 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" + indicator.text = "Connecting to cluster..." + runBlockingCancellable { + strategy.authenticate( + selectedCluster, + server, + tlsContext, + devSpacesContext, + indicator, ) } - false + + settings.save(selectedCluster) + onFinished(true) + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Exception) { + handleConnectionFailure(server, e) + onFinished(false) + } finally { + connectInProgress = false } - ) + } + } + + private fun handleConnectionFailure(server: String, e: Throwable) { + 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" + ) + } } private fun confirmAuthSwitchIfNeeded(): Boolean { @@ -487,7 +512,7 @@ class DevSpacesServerStepView( password = CharArray(0) ) - private val tlsTrustManager = DefaultTlsTrustManager( + private val tlsTrustManager: TlsTrustManager = DefaultTlsTrustManager( kubeConfigProvider = { withContext(Dispatchers.IO) { KubeConfigUtils.getAllConfigs( @@ -504,60 +529,95 @@ class DevSpacesServerStepView( persistentKeyStore = persistentKeyStore ) - private suspend fun resolveSslContext(serverUrl: String): TlsContext { - return tlsTrustManager.ensureTrusted( - serverUrl = serverUrl, - decisionHandler = UiTlsDecisionAdapter::decide - ) + private suspend fun resolveTlsContext( + serverUrl: String, + authMethod: AuthMethod, + certificateAuthority: CertificateSource?, + ): TlsContext { + val decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision = { info -> + UITlsDecisionAdapter.decide(info, component) + } + return when (authMethod) { + AuthMethod.OPENSHIFT, + AuthMethod.OPENSHIFT_CREDENTIALS -> + tlsTrustManager.createOpenShiftTlsContext( + serverUrl, + decisionHandler, + certificateAuthority + ) + else -> + tlsTrustManager.createTlsContext( + serverUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.UNKNOWN, + ) + } + } + + 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 + if (!saveConfigForConnect || token.isBlank()) return try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { - KubeConfigUpdate - .create( - cluster.name.trim(), - cluster.url.trim(), - token.trim()) - .apply() - } - + applyKubeconfigTokenUpdate(cluster, token) } 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") } } } private suspend fun saveKubeconfigWithCert(cluster: Cluster, clientCertPem: String, clientKeyPem: String, indicator: ProgressIndicator) { - if (!saveConfig + if (!saveConfigForConnect || clientCertPem.isBlank() || clientKeyPem.isBlank()) return try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { - KubeConfigUpdate - .create( - cluster.name.trim(), - cluster.url.trim(), - clientCertPem.trim(), - clientKeyPem.trim()) - .apply() - } + applyKubeconfigClientCertUpdate(cluster, clientCertPem, clientKeyPem) } 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") } } } + /** + * Blocking kubeconfig write on the progress background thread. + * Avoids nesting [kotlinx.coroutines.runInterruptible] inside [runBlockingCancellable], which can + * deadlock when the platform handles VFS refresh on the EDT. + */ + private fun applyKubeconfigTokenUpdate(cluster: Cluster, token: String) { + KubeConfigUpdate + .create( + cluster.name.trim(), + cluster.url.trim(), + token.trim(), + ) + .apply() + } + + private fun applyKubeconfigClientCertUpdate(cluster: Cluster, clientCertPem: String, clientKeyPem: String) { + KubeConfigUpdate + .create( + cluster.name.trim(), + cluster.url.trim(), + clientCertPem.trim(), + clientKeyPem.trim(), + ) + .apply() + } + private fun setClusters(clusters: List) { this.tfServer.removeAllItems() clusters.forEach { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt index 2e4859a0..7ee2ffe1 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt @@ -24,11 +24,23 @@ sealed interface DevSpacesWizardStep { fun onNext(): Boolean + /** + * Optional background work before advancing (e.g. cluster connect on the server step). + * When non-null, the wizard runs this on a background thread and advances when the work + * reports success via its [WizardAsyncWork] callback. + */ + fun startAsyncNext(): WizardAsyncWork? = null + /** * Determines if the next button should be enabled. * Default implementation returns true. */ fun isNextEnabled(): Boolean = true + /** + * Whether Previous/Next navigation is allowed. Disabled while async work runs. + */ + fun isNavigationEnabled(): Boolean = true + fun onDispose() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/WizardAsyncWork.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/WizardAsyncWork.kt new file mode 100644 index 00000000..cd7b7d64 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/WizardAsyncWork.kt @@ -0,0 +1,74 @@ +/* + * 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.view.steps + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import java.util.concurrent.atomic.AtomicInteger + +/** + * Background work for a wizard step that may show modal UI (e.g. TLS trust dialogs) + * before the step advances. + * + * @param onFinished call when done; `true` means advance to the next step. The companion + * [execute] delivers this callback on the EDT. + */ +fun interface WizardAsyncWork { + fun run(indicator: ProgressIndicator, onFinished: (Boolean) -> Unit) + + companion object { + private val generation = AtomicInteger(0) + + /** + * Runs [work] on a cancellable background task. Stale callbacks are ignored after + * [invalidatePending] or when a newer [execute] call starts. + */ + fun execute( + title: String, + work: WizardAsyncWork, + onFinished: (Boolean) -> Unit, + ) { + val gen = generation.incrementAndGet() + ProgressManager.getInstance().run( + object : Task.Backgroundable(null, title, true) { + override fun run(indicator: ProgressIndicator) { + work.run(indicator) { advance -> + finishOnEdt(gen, advance, onFinished) + } + } + + override fun onCancel() { + finishOnEdt(gen, false, onFinished) + } + + override fun onThrowable(error: Throwable) { + finishOnEdt(gen, false, onFinished) + } + }, + ) + } + + fun invalidatePending() { + generation.incrementAndGet() + } + + private fun finishOnEdt(gen: Int, advance: Boolean, onFinished: (Boolean) -> Unit) { + ApplicationManager.getApplication().invokeLater { + if (gen == generation.get()) { + onFinished(advance) + } + } + } + } +} 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..230cfe37 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.apiclient.TlsClientBuilder import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient @@ -25,8 +24,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 +67,71 @@ 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 = + TlsClientBuilder( + server = server, + token = token, + clientCert = null, + clientKey = null, + tlsContext = tlsContext, + ).build() + + /** + * 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) + TlsClientBuilder( + server = server, + token = token, + clientCert = certSource, + clientKey = keySource, + tlsContext = tlsContext + ).build() + .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/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 450589d2..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 @@ -88,12 +87,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..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 @@ -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 @@ -104,7 +104,6 @@ class OpenShiftCredentialsAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -120,7 +119,7 @@ class OpenShiftCredentialsAuthenticationStrategy( apiServerUrl = selectedCluster.url, username = username, password = password, - tlsContext.sslContext + tlsContext.sslContext, ) val finalToken = TokenModel( @@ -131,17 +130,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..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 @@ -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 @@ -51,53 +52,45 @@ class OpenShiftOAuthAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, 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..8d4be503 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 @@ -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 @@ -51,7 +52,6 @@ class RedHatSSOAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -59,41 +59,48 @@ class RedHatSSOAuthenticationStrategy( indicator.text = "Authenticating with Red Hat..." val login = sessionManager.startBrowserLogin(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() } - - val ssoToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - indicator.text = "Obtaining OpenShift access..." - - val sandboxAuth = SandboxClusterAuthProvider() - val finalToken = sandboxAuth.authenticate(ssoToken) - - indicator.text = "Validating cluster access..." - + supervisorScope { + val cancelJob = launchCancelWatcher(indicator) { login.cancel() } try { - val client = createValidatedApiClient( - server, certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." - ) - - // Do not save SSO tokens - if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + val ssoToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) + indicator.text = "Obtaining OpenShift access..." + + val sandboxAuth = SandboxClusterAuthProvider() + val finalToken = sandboxAuth.authenticate(ssoToken) + + indicator.text = "Validating cluster access..." + + try { + val client = createValidatedApiClient( + server, + finalToken.accessToken, + tlsContext = tlsContext, + errorMessage = "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) + + devSpacesContext.client = client + + // Only persist the long-lived pipeline service-account token. + // Never save the Red Hat SSO/OIDC token — it is short-lived and not a cluster API credential. + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } + } catch (e: AuthenticationException) { + throw AuthenticationException( + "${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", + e + ) } - devSpacesContext.client = client - } catch (e: AuthenticationException) { - throw AuthenticationException("${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", e) + } finally { + cancelJob.cancel() } } } 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..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 @@ -96,12 +95,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) 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'") + } +} 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..451c5f0d --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt @@ -0,0 +1,59 @@ +/* + * 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.auth.tls.TlsTrustManagerTestFixtures.API_SERVER_URL +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.createManager +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.kubeConfigWithInsecureSkip +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import javax.net.ssl.X509TrustManager + +class DefaultTlsTrustManagerCaTest { + + @Test + fun `#mergeTrustedContext fails when no trusted certificates resolved`() { + runBlocking { + val manager = createManager() + val exception = kotlin.runCatching { + manager.mergeTrustedContext(listOf(API_SERVER_URL), certificateAuthority = null) + }.exceptionOrNull() + + assertThat(exception) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining(API_SERVER_URL) + } + } + + @Test + fun `#createOpenShiftTlsContext honors kubeconfig insecure-skip-tls-verify`() { + runBlocking { + val manager = createManager( + kubeConfigProvider = { listOf(kubeConfigWithInsecureSkip()) }, + ) + + val tlsContext = manager.createOpenShiftTlsContext( + API_SERVER_URL, + decisionHandler = { + error("trust dialog must not be shown when insecure-skip-tls-verify is set") + }, + ) + + assertThat(tlsContext.isInsecure).isTrue() + + val trustManager = tlsContext.trustManager as X509TrustManager + trustManager.checkServerTrusted(emptyArray(), "RSA") + assertThat(trustManager.acceptedIssuers).isEmpty() + } + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt new file mode 100644 index 00000000..0d805b65 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt @@ -0,0 +1,264 @@ +/* + * 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.auth.tls.TlsTrustManagerTestFixtures.API_SERVER_URL +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.OAUTH_URL_1 +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.OAUTH_URL_2 +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.createManager +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.kubeConfigWithCa +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.kubeConfigWithInsecureSkip +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.simulatingTlsProbe +import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.successTlsProbe +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import javax.net.ssl.X509TrustManager + +class DefaultTlsTrustManagerTrustTest { + + private val serverCert = TlsTestCertificates.caCertificate() + + @Test + fun `#createTlsContext honors kubeconfig insecure-skip-tls-verify`() { + runBlocking { + val manager = createManager( + kubeConfigProvider = { listOf(kubeConfigWithInsecureSkip()) }, + ) + + val tlsContext = manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + error("trust dialog must not be shown when insecure-skip-tls-verify is set") + }, + ) + + assertThat(tlsContext.isInsecure).isTrue() + } + } + + @Test + fun `#createTlsContext uses wizard CA fast path without prompting`() { + runBlocking { + var prompted = false + val manager = createManager(tlsProbe = successTlsProbe()) + + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ) + + assertThat(prompted).isFalse() + } + } + + @Test + fun `#createTlsContext prompts on unknown cert and stores session trust`() { + runBlocking { + val sessionStore = SessionTlsTrustStore() + val manager = createManager( + sessionTrustStore = sessionStore, + tlsProbe = simulatingTlsProbe(serverCert), + ) + + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { TlsTrustDecision.sessionOnly() }, + ) + + assertThat(sessionStore.get(API_SERVER_URL).map { it.serialNumber }) + .containsExactly(serverCert.serialNumber) + } + } + + @Test + fun `#createTlsContext rejects when user rejects certificate`() { + runBlocking { + val manager = createManager(tlsProbe = simulatingTlsProbe(serverCert)) + + val exception = kotlin.runCatching { + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { TlsTrustDecision.reject() }, + ) + }.exceptionOrNull() + + assertThat(exception).isInstanceOf(TlsTrustRejectedException::class.java) + } + } + + @Test + fun `#createOpenShiftTlsContext probes API and OAuth endpoints`() { + runBlocking { + val probedHosts = mutableListOf() + val manager = createManager( + tlsProbe = simulatingTlsProbe( + serverCertificate = serverCert, + probedHosts = probedHosts, + failTrustedProbeForHosts = setOf("oauth.example.com", "oauth2.example.com"), + ), + oauthDiscovery = { _, _ -> listOf(OAUTH_URL_1, OAUTH_URL_2) }, + ) + + manager.createOpenShiftTlsContext( + API_SERVER_URL, + decisionHandler = { TlsTrustDecision.sessionOnly() }, + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ) + + assertThat(probedHosts).contains("api.example.com", "oauth.example.com", "oauth2.example.com") + } + } + + @Test + fun `#createOpenShiftTlsContext OAuth prompt uses OAUTH endpoint kind`() { + runBlocking { + val prompted = mutableListOf() + val manager = createManager( + tlsProbe = simulatingTlsProbe( + serverCertificate = serverCert, + failTrustedProbeForHosts = setOf("oauth.example.com"), + ), + oauthDiscovery = { _, _ -> listOf(OAUTH_URL_1) }, + ) + + manager.createOpenShiftTlsContext( + API_SERVER_URL, + decisionHandler = { info -> + prompted.add(info) + TlsTrustDecision.sessionOnly() + }, + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ) + + assertThat(prompted).hasSize(1) + assertThat(prompted.single().endpointKind).isEqualTo(TlsEndpointKind.OAUTH) + assertThat(prompted.single().serverUrl).isEqualTo(OAUTH_URL_1) + } + } + + @Test + fun `#createOpenShiftTlsContext skips OAuth prompt when cert already trusted`() { + runBlocking { + val sessionStore = SessionTlsTrustStore().apply { + put(OAUTH_URL_1, listOf(serverCert)) + } + var prompted = false + val manager = createManager( + sessionTrustStore = sessionStore, + tlsProbe = successTlsProbe(), + oauthDiscovery = { _, _ -> listOf(OAUTH_URL_1) }, + ) + + manager.createOpenShiftTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ) + + assertThat(prompted).isFalse() + } + } + + @Test + fun `#mergeTrustedContext includes wizard certificate authority`() { + runBlocking { + val manager = createManager() + + val tlsContext = manager.mergeTrustedContext( + listOf(API_SERVER_URL), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).contains(serverCert.serialNumber) + } + } + + @Test + fun `#mergeTrustedContext uses session trust when certificates already accepted`() { + runBlocking { + val sessionStore = SessionTlsTrustStore().apply { + put(API_SERVER_URL, listOf(serverCert)) + } + val manager = createManager(sessionTrustStore = sessionStore) + + val tlsContext = manager.mergeTrustedContext( + listOf(API_SERVER_URL), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).containsExactly(serverCert.serialNumber) + } + } + + @Test + fun `#mergeTrustedContext deduplicates same cert across API and OAuth URLs`() { + runBlocking { + val sessionStore = SessionTlsTrustStore().apply { + put(API_SERVER_URL, listOf(serverCert)) + put(OAUTH_URL_1, listOf(serverCert)) + } + val manager = createManager(sessionTrustStore = sessionStore) + + val tlsContext = manager.mergeTrustedContext( + listOf(API_SERVER_URL, OAUTH_URL_1), + certificateAuthority = null, + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).containsExactly(serverCert.serialNumber) + } + } + + @Test + fun `#mergeTrustedContext prefers session trust over kubeconfig CA`() { + runBlocking { + val kubeCa = TlsTestCertificates.caCertificate() + val sessionStore = SessionTlsTrustStore().apply { + put(API_SERVER_URL, listOf(kubeCa)) + } + val manager = createManager( + kubeConfigProvider = { listOf(kubeConfigWithCa()) }, + sessionTrustStore = sessionStore, + ) + + val tlsContext = manager.mergeTrustedContext( + listOf(API_SERVER_URL), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).containsExactly(kubeCa.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)) +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt new file mode 100644 index 00000000..01d182df --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt @@ -0,0 +1,100 @@ +/* + * 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.KubeConfigTestHelpers +import io.kubernetes.client.util.KubeConfig +import java.net.URI +import java.nio.file.Files +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLHandshakeException + +object TlsTrustManagerTestFixtures { + + const val API_SERVER_URL = "https://api.example.com:6443" + const val OAUTH_URL_1 = "https://oauth.example.com" + const val OAUTH_URL_2 = "https://oauth2.example.com" + + fun tempPersistentKeyStore(): PersistentKeyStore = + PersistentKeyStore(Files.createTempDirectory("tls-trust").resolve("truststore.p12")) + + fun kubeConfigWithInsecureSkip(serverUrl: String = API_SERVER_URL): KubeConfig = + KubeConfigTestHelpers.createMockKubeConfig( + clusters = listOf( + mapOf( + "name" to "dogfood", + "cluster" to mapOf( + "server" to serverUrl, + "insecure-skip-tls-verify" to true, + ), + ), + ), + ) + + fun kubeConfigWithCa( + serverUrl: String = API_SERVER_URL, + caCert: X509Certificate = TlsTestCertificates.caCertificate(), + ): KubeConfig = + KubeConfigTestHelpers.createMockKubeConfig( + clusters = listOf( + mapOf( + "name" to "dogfood", + "cluster" to mapOf( + "server" to serverUrl, + "certificate-authority-data" to KubeConfigCertEncoder.encode(caCert), + ), + ), + ), + ) + + fun createManager( + kubeConfigProvider: suspend () -> List = { emptyList() }, + kubeConfigWriter: suspend (com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster, List) -> Unit = { _, _ -> }, + sessionTrustStore: SessionTlsTrustStore = SessionTlsTrustStore(), + persistentKeyStore: PersistentKeyStore = tempPersistentKeyStore(), + tlsProbe: (URI, TlsContext) -> Unit = successTlsProbe(), + oauthDiscovery: suspend (String, SSLContext) -> List = { _, _ -> emptyList() }, + ): DefaultTlsTrustManager = + DefaultTlsTrustManager( + kubeConfigProvider = kubeConfigProvider, + kubeConfigWriter = kubeConfigWriter, + sessionTrustStore = sessionTrustStore, + persistentKeyStore = persistentKeyStore, + tlsProbe = tlsProbe, + oauthDiscovery = oauthDiscovery, + ) + + fun successTlsProbe(): (URI, TlsContext) -> Unit = { _, _ -> } + + fun simulatingTlsProbe( + serverCertificate: X509Certificate = TlsTestCertificates.caCertificate(), + probedHosts: MutableList = mutableListOf(), + failTrustedProbeForHosts: Set = emptySet(), + ): (URI, TlsContext) -> Unit { + val trustedProbeAttempts = mutableMapOf() + return { uri, tlsContext -> + probedHosts.add(uri.host) + if (tlsContext.isCapturingProbe) { + val capturingTrustManager = tlsContext.trustManager as CapturingTrustManager + capturingTrustManager.checkServerTrusted(arrayOf(serverCertificate), "RSA") + throw SSLHandshakeException("simulated capture probe failure") + } + if (uri.host in failTrustedProbeForHosts) { + val attempts = trustedProbeAttempts.merge(uri.host, 1, Int::plus) ?: 1 + if (attempts == 1) { + throw SSLHandshakeException("simulated trusted probe failure") + } + } + } + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt index d0b49cd9..07b9889d 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt @@ -38,14 +38,14 @@ class KubeConfigClusterTest { @Test fun `#fromMap parses cluster with certificate-authority path`() { + // given val map = mapOf( "server" to "https://api.example.com:6443", "certificate-authority" to "/home/user/.minikube/ca.crt" ) - val cluster = KubeConfigCluster.fromMap(map) - assertThat(cluster).isNotNull + // when, then assertThat(cluster?.certificateAuthority?.value).isEqualTo("/home/user/.minikube/ca.crt") assertThat(cluster?.certificateAuthority?.isFilePath).isTrue() } @@ -173,4 +173,26 @@ class KubeConfigClusterTest { .hasSize(1) .containsEntry("server", "https://endor.starwars.galaxy:6443") } + + @Test + fun `#isSkipTlsVerify returns true when insecure-skip-tls-verify is set`() { + // given + val cluster = KubeConfigCluster( + server = "https://api.example.com:6443", + insecureSkipTlsVerify = true + ) + // when, then + assertThat(cluster.isSkipTlsVerify()).isTrue() + } + + @Test + fun `#isSkipTlsVerify returns false when insecure-skip-tls-verify is null`() { + // given + val cluster = KubeConfigCluster( + server = "https://api.example.com:6443" + ) + // when, then + assertThat(cluster.isSkipTlsVerify()).isFalse() + } + } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt index b14ca9fc..d8452770 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigNamedClusterTest.kt @@ -70,6 +70,16 @@ class KubeConfigNamedClusterTest { assertThat(namedCluster).isNull() } + @Test + fun `#isSkipTlsVerify delegates to cluster isSkipTlsVerify`() { + val namedCluster = KubeConfigNamedCluster( + name = "Death-Star-Cluster", + cluster = KubeConfigCluster(server = "https://alderaan.starwars.galaxy", insecureSkipTlsVerify = true) + ) + + assertThat(namedCluster.isSkipTlsVerify()).isTrue() + } + @Test fun `#toMap returns map representation`() { // given diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt index c70ceb1e..985e43f4 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigTestHelpers.kt @@ -16,7 +16,6 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import io.kubernetes.client.util.KubeConfig import io.mockk.every import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat import java.nio.file.Path object KubeConfigTestHelpers { diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtilsTest.kt index 95eda540..3679e380 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtilsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ApiClientUtilsTest.kt @@ -8,6 +8,7 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.openshift.apiclient.ApiClientUtils import io.kubernetes.client.openapi.ApiClient import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test 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..2ed06864 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt @@ -0,0 +1,209 @@ +/* + * 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.openshift.apiclient.DefaultClientBuilder +import com.redhat.devtools.gateway.openshift.apiclient.TokenClientBuilder +import com.redhat.devtools.gateway.openshift.apiclient.TlsClientBuilder +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/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() + } +} 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