From 4f6207015ab7a96c923db2288bafbe4bbe5531ba Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 14 Jul 2026 17:41:06 +0200 Subject: [PATCH 1/6] fix: add missing ApiClientUtils import in DevWorkspacePodsTest Add the missing import for ApiClientUtils, remove unused imports, and suppress warnings in DevWorkspacePodsTest. --- .../gateway/openshift/DevWorkspacePodsTest.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt index f1435d6e..e13a7c3c 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt @@ -11,7 +11,9 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.openshift.apiclient.ApiClientUtils import io.kubernetes.client.PortForward +import io.kubernetes.client.custom.IOTrio import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CoreV1Api @@ -27,14 +29,11 @@ import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll import io.mockk.unmockkConstructor -import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import okhttp3.ConnectionPool -import okhttp3.Dispatcher import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.AfterEach @@ -47,7 +46,6 @@ import java.io.PipedInputStream import java.io.PipedOutputStream import java.net.ServerSocket import java.net.Socket -import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds class DevWorkspacePodsTest { @@ -574,8 +572,9 @@ class DevWorkspacePodsTest { any(), any(), any(), any(), any(), any(), any(), any(), any() ) } answers { - val onOpen = it.invocation.args[4] as java.util.function.Consumer - val io = io.kubernetes.client.custom.IOTrio() + @Suppress("UNCHECKED_CAST") + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() io.stdout = fakeProcess.inputStream io.stderr = fakeProcess.errorStream io.stdin = fakeProcess.outputStream @@ -605,6 +604,7 @@ class DevWorkspacePodsTest { checkCancelled = { throw CancellationException("user cancelled") } ) } + @Suppress("ConvertLongToDuration") delay(100) // let exec start job.cancel() job.join() @@ -671,8 +671,9 @@ class DevWorkspacePodsTest { any(), any(), any(), any(), any(), any(), any(), any(), any() ) } answers { - val onOpen = it.invocation.args[4] as java.util.function.Consumer - val io = io.kubernetes.client.custom.IOTrio() + @Suppress("UNCHECKED_CAST") + val onOpen = it.invocation.args[4] as java.util.function.Consumer + val io = IOTrio() io.stdout = fakeProcess.inputStream io.stderr = fakeProcess.errorStream io.stdin = fakeProcess.outputStream @@ -701,6 +702,7 @@ class DevWorkspacePodsTest { timeout = 60 ) } + @Suppress("ConvertLongToDuration") delay(100) job.cancel() job.join() From 3bfcede49fe75d7a1add615f1638762de1658ded Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 3 Jul 2026 15:57:55 +0200 Subject: [PATCH 2/6] fix: use CA field in TLS trust resolution and sequentially show TLS trust dialogs (crw-11253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: skip unreadable kubeconfig CA paths during TLS setup - fix: build token API client from wizard TLS context - refactor: client factory -> builder - refactor: removed duplicate http request codes - refactor: extracted #getTrustedCerts for better readable code - refactor: move cluster-by-server lookup to KubeConfigUtils - fix: wire wizard CA field into TLS trust resolution - fix: show sequential TLS trust dialogs during OpenShift connect - refactor: extracted OAuthDiscovery for better readable OpenShiftAuthCodeFlow class - fix: insecure-skip-tls-verify is honoured by OAuth strategy - fix: improve TLS trust dialog UI — clickable URL, text wrapping, certificate display - fix: avoid deadlock when displaying trust dialog - refactor: symmetrize OpenShift TLS trust establishment - refactor: extracted apiclient builders to own files and package - fix: TokenClientBuilder is using ssl trust - refactor: address review complaints Signed-off-by: Andre Dietisheim Co-authored-by: Cursor --- .gitignore | 3 + .../gateway/DevSpacesConnectionProvider.kt | 7 +- .../gateway/auth/code/HttpClientExtensions.kt | 67 +++ .../gateway/auth/code/OAuthDiscovery.kt | 68 ++++ .../auth/code/OpenShiftAuthCodeFlow.kt | 216 ++++------ .../gateway/auth/code/RedHatAuthCodeFlow.kt | 56 +-- .../sandbox/SandboxClusterAuthProvider.kt | 31 +- .../auth/tls/DefaultTlsTrustManager.kt | 384 ++++++++++++++---- .../gateway/auth/tls/KubeConfigTlsUtils.kt | 30 +- .../gateway/auth/tls/SslContextFactory.kt | 20 +- .../devtools/gateway/auth/tls/TlsContext.kt | 6 +- .../gateway/auth/tls/TlsEndpointKind.kt | 19 + .../auth/tls/TlsServerCertificateInfo.kt | 3 +- .../gateway/auth/tls/TlsTrustManager.kt | 18 +- .../auth/tls/ui/TLSTrustDecisionHandler.kt | 101 ----- .../auth/tls/ui/TlsTrustDecisionDialog.kt | 151 +++++++ .../auth/tls/ui/UITlsDecisionAdapter.kt | 72 ++++ .../auth/tls/ui/UiTlsDecisionAdapter.kt | 52 --- .../kubeconfig/BlockStyleFilePersister.kt | 10 +- .../gateway/kubeconfig/KubeConfigEntries.kt | 8 +- .../gateway/kubeconfig/KubeConfigUtils.kt | 15 + .../gateway/openshift/ApiExceptionUtils.kt | 2 +- .../gateway/openshift/DevWorkspacePods.kt | 1 + .../openshift/OpenShiftClientFactory.kt | 321 --------------- .../{ => apiclient}/ApiClientUtils.kt | 2 +- .../openshift/apiclient/BaseClientBuilder.kt | 68 ++++ .../apiclient/ClientCertClientBuilder.kt | 89 ++++ .../openshift/apiclient/LinkClientBuilder.kt | 51 +++ .../openshift/apiclient/TokenClientBuilder.kt | 36 ++ .../redhat/devtools/gateway/util/UrlUtils.kt | 8 + .../gateway/view/DevSpacesWizardView.kt | 11 +- .../view/steps/DevSpacesServerStepView.kt | 127 ++++-- .../auth/AbstractAuthenticationStrategy.kt | 71 +++- .../view/steps/auth/AuthenticationStrategy.kt | 5 +- ...ClientCertificateAuthenticationStrategy.kt | 11 +- ...nShiftCredentialsAuthenticationStrategy.kt | 14 +- .../OpenShiftOAuthAuthenticationStrategy.kt | 14 +- .../auth/RedHatSSOAuthenticationStrategy.kt | 63 +-- .../steps/auth/TokenAuthenticationStrategy.kt | 8 +- .../gateway/auth/code/OAuthDiscoveryTest.kt | 104 +++++ .../auth/code/OpenShiftAuthCodeFlowTest.kt | 118 ++++++ .../auth/tls/DefaultTlsTrustManagerCaTest.kt | 59 +++ .../tls/DefaultTlsTrustManagerTrustTest.kt | 283 +++++++++++++ .../auth/tls/KubeConfigTlsUtilsTest.kt | 71 ++++ .../gateway/auth/tls/TlsTestCertificates.kt | 48 +++ .../auth/tls/TlsTrustManagerTestFixtures.kt | 103 +++++ .../kubeconfig/KubeConfigClusterTest.kt | 26 +- .../kubeconfig/KubeConfigNamedClusterTest.kt | 10 + .../kubeconfig/KubeConfigTestHelpers.kt | 1 - .../gateway/openshift/ApiClientUtilsTest.kt | 1 + .../openshift/OpenShiftClientBuilderTest.kt | 172 ++++++++ .../gateway/util/ExceptionUtilsTest.kt | 33 ++ 52 files changed, 2353 insertions(+), 915 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsEndpointKind.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TlsTrustDecisionDialog.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt rename src/main/kotlin/com/redhat/devtools/gateway/openshift/{ => apiclient}/ApiClientUtils.kt (98%) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/BaseClientBuilder.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ClientCertClientBuilder.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscoveryTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlowTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt 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 3be89ce9..08948051 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -25,12 +25,7 @@ import com.redhat.devtools.gateway.openshift.toWorkspaceException import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.view.SelectClusterDialog 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 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..af0b2ead --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt @@ -0,0 +1,67 @@ +/* + * 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 = 0L, + @SerialName("id_token") val idToken: String = "" +) + +suspend fun HttpClient.sendGetRequest( + url: String, + errorPrefix: String = "Request to $url failed" +): HttpResponse { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(java.time.Duration.ofSeconds(30)) + .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)) + .timeout(java.time.Duration.ofSeconds(30)) + .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/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt index 922c3e86..0ea5210c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -22,19 +22,11 @@ import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.AuthenticationRequest import com.nimbusds.openid.connect.sdk.Nonce import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata -import kotlinx.coroutines.future.await -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse import java.nio.charset.StandardCharsets -import java.util.* -import javax.net.ssl.SSLContext +import java.util.Base64 /** * RedHat SSO OAuth Flow. @@ -83,7 +75,6 @@ class RedHatAuthCodeFlow( override suspend fun handleCallback(parameters: Parameters): SSOToken { val code = parameters["code"] ?: error("Missing 'code' parameter in callback") - val form = encodeForm( "grant_type" to "authorization_code", "client_id" to clientId, @@ -91,44 +82,23 @@ class RedHatAuthCodeFlow( "redirect_uri" to redirectUri.toString(), "code_verifier" to codeVerifier.value ) - val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("$clientId:".toByteArray(StandardCharsets.UTF_8)) - - val request = HttpRequest.newBuilder() - .uri(providerMetadata.tokenEndpointURI) - .header("Authorization", basicAuth) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() - - val response = httpClient.sendAsync( - request, - HttpResponse.BodyHandlers.ofString() - ).await() - - if (response.statusCode() !in 200..299) { - error("Token request failed: ${response.statusCode()}\n${response.body()}") - } - - return createSSOToken(response.body()) + val token = httpClient.sendPostRequest( + providerMetadata.tokenEndpointURI.toString(), + basicAuth, + form, + errorPrefix = "Token request failed" + ) + return createSSOToken(token) } - private fun createSSOToken(response: String): SSOToken { - val json = Json { ignoreUnknownKeys = true } - val body = json.parseToJsonElement(response).jsonObject - - val accessToken = body["access_token"]?.jsonPrimitive?.content - ?: error("Missing access_token in token response") - - val idToken = body["id_token"]?.jsonPrimitive?.content.orEmpty() - val expiresInSeconds = body["expires_in"]?.jsonPrimitive?.longOrNull ?: 3600 - val accountLabel = createAccountLabel(idToken) - + private fun createSSOToken(token: AccessTokenResponseJson): SSOToken { + val expiresInSeconds = if (token.expiresIn > 0) token.expiresIn else 3600L + val accountLabel = createAccountLabel(token.idToken) return SSOToken( - accessToken = accessToken, - idToken = idToken, + accessToken = token.accessToken, + idToken = token.idToken, expiresAt = System.currentTimeMillis() + expiresInSeconds * 1000, accountLabel = accountLabel ) 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 0ba2807d..d993bf6b 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,35 +14,33 @@ 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.auth.tls.TlsContext +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 import io.kubernetes.client.openapi.models.V1ServiceAccount import kotlinx.coroutines.* -import java.util.concurrent.TimeUnit + class SandboxClusterAuthProvider( - private val sandboxApi: SandboxApi = SandboxApi( + private val tlsContext: TlsContext +) { + private val 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) ?: error("Sandbox not available") - - if (!signup.status.ready) error("Sandbox not ready") - + if (!signup.status.ready) + error("Sandbox not ready") + val proxyUrl = signup.proxyUrl + ?: error("Sandbox proxy URL not available") val username = signup.compliantUsername ?: signup.username val namespace = "$username-dev" - - val client = clientFactory - .builder(signup.proxyUrl!!, ssoToken.idToken) - .readTimeout(30, TimeUnit.SECONDS) + val client = TokenClientBuilder(proxyUrl, ssoToken.idToken, tlsContext) .build() val coreV1Api = CoreV1Api(client) @@ -69,7 +67,8 @@ class SandboxClusterAuthProvider( ?: api.createNamespacedServiceAccount( namespace, V1ServiceAccount().metadata(V1ObjectMeta().name("pipeline")) - ).execute() ?: error("Failed to create pipeline ServiceAccount") + ).execute() + ?: error("Failed to create pipeline ServiceAccount") } private suspend fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret = withContext(Dispatchers.IO) { 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..36ef905e 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,359 @@ */ 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( + override suspend fun createTlsContext( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource?, + endpointKind: TlsEndpointKind ): TlsContext { val serverUri = URI(serverUrl) + thisLogger().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 } + trySystemTrustProbe(serverUrl, serverUri)?.let { return it } - val keyStore = persistentKeyStore.loadOrCreate() - val persistentAlias = "host:${serverUri.host}" + val certInfo = captureServerCertificate(serverUri, trustedCerts) + ?: run { + thisLogger().warn("TLS trust: probe unexpectedly succeeded without trust for $serverUrl") + return trySystemTrustProbe(serverUrl, serverUri) + ?: SslContextFactory.captureOnly() + } + val info = TlsServerCertificateInfo( + serverUrl = serverUrl, + certificateChain = certInfo.chain, + fingerprintSha256 = sha256Fingerprint(certInfo.trustAnchor), + problem = certInfo.problem, + endpointKind = endpointKind, + ) + thisLogger().info("TLS trust: prompting user for ${endpointKind.label} at $serverUrl") + return persistAndVerifyAcceptedTrust( + serverUrl = serverUrl, + trustedCerts = trustedCerts, + namedCluster = namedCluster, + serverUri = serverUri, + certInfo = info, + decisionHandler = decisionHandler, + ) + } - val persistentCert = keyStore.getCertificate(persistentAlias) - if (persistentCert is X509Certificate) { - trustedCerts += persistentCert + private fun checkForInsecureSkipTlsVerify( + namedCluster: KubeConfigNamedCluster?, + serverUrl: String + ): Boolean { + if (namedCluster?.isSkipTlsVerify() == true) { + thisLogger().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 trySystemTrustProbe( + serverUrl: String, + serverUri: URI + ): TlsContext? { + return try { + val tlsContext = SslContextFactory.fromSystemTrust() + withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + thisLogger().info("TLS trust: server $serverUrl trusted by JVM certificate authorities") + tlsContext + } catch (e: SSLHandshakeException) { + thisLogger().debug("TLS trust: JVM CAs do not trust $serverUrl (${e.message})") + null } + } - val captureContext = SslContextFactory.captureOnly() + private suspend fun tryTrustedCertProbe( + serverUrl: String, + serverUri: URI, + trustedCerts: List + ): TlsContext? { + if (trustedCerts.isEmpty()) { + thisLogger().info("TLS trust: no known certificate for $serverUrl; will capture server certificate") + return null + } - try { - withContext(Dispatchers.IO) { - TlsProbe.connect(serverUri, captureContext.sslContext) - } - return captureContext // should not normally succeed + thisLogger().debug( + "TLS trust: trying ${trustedCerts.size} known certificate(s) for $serverUrl " + + "(session=${sessionTrustStore.get(serverUrl).size}, " + + "preconfigured=${trustedCerts.size - sessionTrustStore.get(serverUrl).size})" + ) + + return try { + val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) + withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + thisLogger().info("TLS trust: existing trust accepted for $serverUrl") + tlsContext + } catch (e: SSLHandshakeException) { + thisLogger().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 { + thisLogger().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) { + thisLogger().info("TLS trust: user rejected certificate for $serverUrl") + throw TlsTrustRejectedException() + } - TlsTrustScope.PERMANENT -> { - sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + thisLogger().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) } + thisLogger().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() + thisLogger().info("TLS trust: establishing OpenShift TLS context for API $apiBaseUrl") + + val apiTlsContext = createTlsContext( + apiBaseUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.API_SERVER, + ) + + if (apiTlsContext.isInsecure) { + return apiTlsContext + } + + val oauthUrls = getOAuthUrls(apiBaseUrl, apiTlsContext) + val endpointContexts = listOf(apiTlsContext) + oauthUrls.map { oauthUrl -> + createTlsContext( + oauthUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.OAUTH, + ) + } + if (endpointContexts.all { it.usesSystemTrust }) { + thisLogger().info( + "TLS trust: OpenShift TLS context uses JVM certificate authorities for " + + "${endpointContexts.size} endpoint(s)" + ) + return apiTlsContext + } - val finalCerts = (trustedCerts + trustAnchor) - .distinctBy { it.serialNumber } + val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() + val merged = mergeTrustedContext(allUrls, certificateAuthority) + thisLogger().info( + "TLS trust: OpenShift TLS context ready for ${allUrls.size} endpoint(s): " + + allUrls.joinToString() + ) + return merged + } - return SslContextFactory.fromTrustedCerts(finalCerts) + private suspend fun getOAuthUrls(apiBaseUrl: String, apiTlsContext: TlsContext): List { + val oauthUrls = oauthDiscovery(apiBaseUrl, apiTlsContext.sslContext) + if (oauthUrls.isEmpty()) { + thisLogger().warn( + "TLS trust: OAuth discovery returned no endpoints for $apiBaseUrl. " + + "Only the API server certificate will be trusted." + ) + } else { + thisLogger().info("TLS trust: discovered OAuth endpoint host(s): ${oauthUrls.joinToString()}") } + return oauthUrls + } + + 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: + * - Session certificates for each URL (from TLS wizard), when present for that URL + * - CA certificates from an optional [certificateAuthority], when no session trust exists for a URL + * - CA certificates from each named cluster in kubeconfig (one per URL), when no session trust exists + * - Persistent certificate for each host from the persistent keystore, when no session trust exists + * + *

Session trust is evaluated per URL. Only URLs without session certificates fall through + * to CA, kubeconfig, and persistent store sources.

+ * + * @param serverUrls The URLs to resolve certificates for. If empty, returns an empty list. + * @param certificateAuthority Optional CA to add as trusted certificates. + * @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() + + return buildList { + for (serverUrl in serverUrls.distinct()) { + val sessionCertsForUrl = sessionTrustStore.get(serverUrl) + if (sessionCertsForUrl.isEmpty()) { + certificateAuthority?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + KubeConfigUtils.getClusterByServer(serverUrl, configs)?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + val persistentCert = keyStore.getCertificate(hostAlias(URI(serverUrl).host)) + if (persistentCert is X509Certificate) { + add(persistentCert) + } + } else { + addAll(sessionCertsForUrl) + } + } + }.distinctBy { it.serialNumber } } - /** Private helper: SHA-256 fingerprint of a certificate */ + 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..92f17b86 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster -import io.kubernetes.client.util.KubeConfig import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 @@ -21,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/SslContextFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt index a72c2c6a..ab47c7d3 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,23 @@ object SslContextFactory { init(null, arrayOf(trustAll), SecureRandom()) } - return TlsContext(sslContext, trustAll) + return TlsContext(sslContext, trustAll, isInsecure = true) + } + + /** TLS context that trusts the JVM default certificate authorities. */ + fun fromSystemTrust(): TlsContext { + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as java.security.KeyStore?) + + val trustManager = tmf.trustManagers + .filterIsInstance() + .first() + + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { + init(null, tmf.trustManagers, SecureRandom()) + } + + return TlsContext(sslContext, trustManager, usesSystemTrust = true) } fun fromTrustedCerts(certs: List): TlsContext { @@ -77,7 +93,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..a36b9bf8 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,9 @@ 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, + /** Trust anchors come from the JVM default CA store (no wizard/kubeconfig/session CAs). */ + val usesSystemTrust: 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/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/TlsTrustDecisionDialog.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TlsTrustDecisionDialog.kt new file mode 100644 index 00000000..6578fcc3 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TlsTrustDecisionDialog.kt @@ -0,0 +1,151 @@ +/* + * 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) + } + + /** trusted (permanent or session). */ + var isTrusted: Boolean = false + private set + + /** permanently trusted, persisted */ + var isPermanent: 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 + isPermanent = true + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Trust for This Session Only") { + override fun doAction(e: ActionEvent) { + isTrusted = true + isPermanent = false + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Cancel") { + override fun doAction(e: ActionEvent) { + isTrusted = false + isPermanent = 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..731559b6 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -0,0 +1,72 @@ +/* + * 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 { + + /** + * @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). + */ + fun decide(info: TlsServerCertificateInfo, parent: Component? = null): TlsTrustDecision { + val dialogParent = parent?.let { SwingUtilities.getWindowAncestor(it) } + thisLogger().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(), + ) + + thisLogger().info("TLS trust: trust dialog closed for ${info.serverUrl}") + + return when { + !dialog.isTrusted -> { + thisLogger().info("TLS trust: user cancelled dialog for ${info.serverUrl}") + TlsTrustDecision.reject() + } + + dialog.isPermanent -> { + thisLogger().info("TLS trust: user chose permanent trust for ${info.serverUrl}") + TlsTrustDecision.permanent() + } + + else -> { + thisLogger().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 c094afb2..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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.ui - -import com.intellij.openapi.application.ApplicationManager -import com.redhat.devtools.gateway.auth.tls.* -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -object UiTlsDecisionAdapter { - - suspend fun decide(info: TlsServerCertificateInfo): TlsTrustDecision = - suspendCancellableCoroutine { continuation -> - ApplicationManager.getApplication().invokeLater { - if (!continuation.isActive) { - return@invokeLater - } - - val dialog = TLSTrustDecisionHandler( - serverUrl = info.serverUrl, - certificateInfo = PemUtils.toPem(info.certificateChain.first()) - ) - dialog.show() - - if (!continuation.isActive) { - return@invokeLater - } - - continuation.resume( - 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 9c7a3636..37d6aa29 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( @@ -192,9 +196,7 @@ data class KubeConfigNamedUser( fun getByName(userName: String, config: KubeConfig?): KubeConfigNamedUser? { return (config?.users ?: emptyList()) - .mapNotNull { - it as? Map<*, *> - } + .filterIsInstance>() .firstOrNull { user -> userName == user["name"] } 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/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index 1d0380b4..f914004f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -102,7 +102,7 @@ fun ApiException.isRetryable(): Boolean = code in setOf(429, 500, 502, 503, 504) fun ApiException.getStatus(): V1Status? { - return responseBody.takeIf { it.isNotEmpty() } + return responseBody?.takeIf { it.isNotEmpty() } ?.let { runCatching { Gson().fromJson(it, V1Status::class.java) } } 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 957764dd..e6536e67 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.custom.IOTrio import io.kubernetes.client.openapi.ApiClient 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..33437d08 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/BaseClientBuilder.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.openshift.apiclient + +import io.kubernetes.client.openapi.ApiClient +import okhttp3.OkHttpClient +import okhttp3.Protocol +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +private const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L + +/** + * 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, HTTP client creation, and URL normalization. + */ +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 + } + + protected fun normalizeBasePath(server: String): String = + server.trim().removeSuffix("/") + + /** + * Creates an [OkHttpClient] with the given [sslContext] and [trustManager]. + * Uses HTTP/1.1 (not HTTP/2): some OpenShift clusters hang on HTTP/2. + */ + protected fun createHttpClient(sslContext: SSLContext, trustManager: X509TrustManager): OkHttpClient = + 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) + .protocols(listOf(Protocol.HTTP_1_1)) + .build() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ClientCertClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ClientCertClientBuilder.kt new file mode 100644 index 00000000..815c9e0d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/ClientCertClientBuilder.kt @@ -0,0 +1,89 @@ +/* + * 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 java.io.IOException +import java.security.KeyStore +import java.security.SecureRandom +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import kotlin.io.path.readText + +/** + * Builder for client-certificate-authenticated API clients. + * Uses the wizard-negotiated [TlsContext] for server CA trust and adds the client certificate. + */ +class ClientCertClientBuilder( + private val server: String, + private val clientCert: CertificateSource, + private val clientKey: CertificateSource, + private val tlsContext: TlsContext +) : BaseClientBuilder() { + override fun build(): ApiClient { + val sslContext = SSLContext.getInstance("TLS").apply { + init( + createKeyManagers(clientCert, clientKey), + arrayOf(tlsContext.trustManager), + SecureRandom() + ) + } + val client = ApiClient(createHttpClient(sslContext, tlsContext.trustManager)) + client.basePath = normalizeBasePath(server) + return applyReadTimeout(client) + } + + 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 + } + + 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 + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.kt new file mode 100644 index 00000000..65f971f2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/LinkClientBuilder.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 API clients used in the deep-link flow (no wizard). + * Reads kubeconfig files and creates an ApiClient from them. + */ +class LinkClientBuilder( + 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/TokenClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt new file mode 100644 index 00000000..d2581a19 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/apiclient/TokenClientBuilder.kt @@ -0,0 +1,36 @@ +/* + * 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.TlsContext +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.util.credentials.AccessTokenAuthentication + +/** + * Builder for token-authenticated API clients. + * Uses the wizard-negotiated [TlsContext] for TLS trust (CA + session trust + persistent store). + */ +class TokenClientBuilder( + private val server: String, + private val token: String, + private val tlsContext: TlsContext +) : BaseClientBuilder() { + override fun build(): ApiClient { + require(token.isNotEmpty()) { + "Provide either token OR clientCert + clientKey." + } + 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/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 43cf7290..da69cd13 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt @@ -43,10 +43,13 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane triggerNextAction = { nextStep() }, ) ) - steps.add(DevSpacesWorkspacesStepView(devSpacesContext) { enableNavigationButtons() }.also { - Disposer.register(this, it) - }) - + steps.add( + DevSpacesWorkspacesStepView(devSpacesContext) + { enableNavigationButtons() } + .also { + Disposer.register(this, it) + } + ) addToBottom(createButtons()) applyStep(0) } 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 7218dd4c..0cb04479 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 @@ -32,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 @@ -63,9 +63,6 @@ class DevSpacesServerStepView( private val triggerNextAction: (() -> Unit)? = null, ) : DevSpacesWizardStep { - private var connectInProgress = false - private var saveConfigForConnect = false - private lateinit var allClusters: List /** @@ -76,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 private var kubeconfigMonitoringActive = false @@ -243,6 +247,32 @@ class DevSpacesServerStepView( override val nextActionText = DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.next") override val previousActionText = DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.previous") + private val sessionTrustStore = SessionTlsTrustStore() + private val persistentKeyStore = PersistentKeyStore( + path = Paths.get( + PathManager.getConfigPath(), + "devspaces", + "tls-truststore.p12" + ), + password = CharArray(0) + ) + + private val tlsTrustManager: TlsTrustManager = DefaultTlsTrustManager( + kubeConfigProvider = { + withContext(Dispatchers.IO) { + KubeConfigUtils.getAllConfigs( + KubeConfigUtils.getAllConfigFiles() + ) + } + }, + kubeConfigWriter = { namedCluster, certs -> + withContext(Dispatchers.IO) { + KubeConfigTlsWriter.write(namedCluster, certs) + } + }, + sessionTrustStore = sessionTrustStore, + persistentKeyStore = persistentKeyStore + ) override fun onInit() { startKubeconfigMonitor() @@ -410,27 +440,45 @@ class DevSpacesServerStepView( ) { indicator, onFinished -> try { indicator.isIndeterminate = true - indicator.text = "Connecting to cluster..." + val certificateAuthority = resolveCertificateAuthority(certAuthorityData) + if (certificateAuthority != null) { + thisLogger().info( + "TLS trust: wizard Certificate Authority provided " + + "(file=${certificateAuthority.isFilePath})" + ) + devSpacesContext.cluster = selectedCluster + } + + indicator.text = "Establishing secure connection..." + val tlsContext = runBlockingCancellable { + resolveTlsContext( + server, + strategy.getAuthMethod(), + certificateAuthority + ) + } + + indicator.text = "Connecting to cluster..." runBlockingCancellable { - val tlsContext = resolveSslContext(server) strategy.authenticate( selectedCluster, server, - certAuthorityData, tlsContext, devSpacesContext, indicator ) - devSpacesContext.cluster = selectedCluster } settings.save(selectedCluster) onFinished(true) } catch (e: ProcessCanceledException) { + startTokenMonitor() + startKubeconfigMonitor() throw e } catch (e: Exception) { handleConnectionFailure(server, e) + startTokenMonitor() startKubeconfigMonitor() onFinished(false) } finally { @@ -492,38 +540,37 @@ class DevSpacesServerStepView( override fun isNextEnabled(): Boolean = currentStrategy?.isNextEnabled() ?: false - private val sessionTrustStore = SessionTlsTrustStore() - private val persistentKeyStore = PersistentKeyStore( - path = Paths.get( - PathManager.getConfigPath(), - "devspaces", - "tls-truststore.p12" - ), - password = CharArray(0) - ) - - private val tlsTrustManager = DefaultTlsTrustManager( - kubeConfigProvider = { - withContext(Dispatchers.IO) { - KubeConfigUtils.getAllConfigs( - KubeConfigUtils.getAllConfigFiles() + 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, + AuthMethod.REDHAT_SSO -> + tlsTrustManager.createOpenShiftTlsContext( + serverUrl, + decisionHandler, + certificateAuthority ) - } - }, - kubeConfigWriter = { namedCluster, certs -> - withContext(Dispatchers.IO) { - KubeConfigTlsWriter.write(namedCluster, certs) - } - }, - sessionTrustStore = sessionTrustStore, - persistentKeyStore = persistentKeyStore - ) + else -> + tlsTrustManager.createTlsContext( + serverUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.UNKNOWN + ) + } + } - private suspend fun resolveSslContext(serverUrl: String): TlsContext { - return tlsTrustManager.ensureTrusted( - serverUrl = serverUrl, - decisionHandler = UiTlsDecisionAdapter::decide - ) + private fun resolveCertificateAuthority(input: String?): CertificateSource? { + val source = CertificateSource.fromPathOrPem(input) ?: return null + source.validate() + return source } private suspend fun createTokenKubeConfigUpdate(cluster: Cluster, token: String, indicator: ProgressIndicator) { @@ -645,6 +692,10 @@ class DevSpacesServerStepView( kubeconfigMonitoringActive = false } + private fun startTokenMonitor() { + findStrategy()?.startMonitoring(component) + } + private class ServerSettings { val service = service() 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 c44cef74..adf1e0ae 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,9 @@ 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.ClientCertClientBuilder +import com.redhat.devtools.gateway.openshift.apiclient.TokenClientBuilder import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient @@ -26,10 +26,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - +import kotlinx.coroutines.runInterruptible /** * Abstract base class for authentication strategies. @@ -88,41 +89,71 @@ abstract class AbstractAuthenticationStrategy( /** * Creates a validated API client on a worker thread. * Must not block the [runBlocking] event-loop thread used during modal progress. + * 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 = + TokenClientBuilder(server, token, 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 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 = withContext(Dispatchers.IO) { + coroutineContext.ensureActive() try { - val caSource = CertificateSource.fromPathOrPem(certAuthority) - caSource?.validate() - val certSource = CertificateSource.fromPathOrPem(clientCert) - certSource?.validate() - val keySource = CertificateSource.fromPathOrPem(clientKey) - keySource?.validate() + val certSource = resolveRequiredCertificateSource(clientCert) + val keySource = resolveRequiredCertificateSource(clientKey) + val usingToken = token?.isNotEmpty() == true + val usingClientCert = certSource != null && keySource != null + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } - OpenShiftClientFactory(KubeConfigUtils) - .create( - server, - caSource, - token?.toCharArray(), - certSource, - keySource, - tlsContext - ) + val apiClient = if (usingClientCert) { + ClientCertClientBuilder(server, certSource, keySource, tlsContext).build() + } else { + TokenClientBuilder(server, token!!, tlsContext).build() + } + apiClient .also { client -> - require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + 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: kotlinx.coroutines.CancellationException) { + throw 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..aa74da5b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -104,7 +104,6 @@ class OpenShiftCredentialsAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -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 76cf4729..7f3d7ecd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -52,7 +52,6 @@ class OpenShiftOAuthAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -86,13 +85,10 @@ class OpenShiftOAuthAuthenticationStrategy( 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." + server = server, + token = finalToken.accessToken, + tlsContext = tlsContext, + errorMessage = "Authentication failed: token received from OpenShift Authenticator is invalid or expired." ) setTokenDisplay(finalToken.accessToken) @@ -100,7 +96,7 @@ class OpenShiftOAuthAuthenticationStrategy( devSpacesContext.client = client } finally { cancelWatcher.cancel() - } + } } override fun isNextEnabled(): Boolean = 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..b170288b 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(tlsContext) + 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..e78b18fa --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt @@ -0,0 +1,283 @@ +/* + * 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 uses JVM trust when no custom certificates are configured`() { + runBlocking { + var prompted = false + val manager = createManager(tlsProbe = successTlsProbe()) + + val tlsContext = manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + ) + + assertThat(prompted).isFalse() + assertThat(tlsContext.usesSystemTrust).isTrue() + } + } + + @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..14b6da09 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.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.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 (tlsContext.usesSystemTrust) { + throw SSLHandshakeException("simulated system trust 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 0802b9fd..0e8875dc 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..b5817266 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt @@ -0,0 +1,172 @@ +/* + * 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.ClientCertClientBuilder +import com.redhat.devtools.gateway.openshift.apiclient.LinkClientBuilder +import com.redhat.devtools.gateway.openshift.apiclient.TokenClientBuilder +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 `TokenClientBuilder sets basePath`() { + val client = TokenClientBuilder( + server = "https://api.example.com:6443/", + token = "test-token", // notsecret + tlsContext = tlsContext, + ).build() + + assertThat(client.basePath).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `ClientCertClientBuilder sets basePath`() { + val client = ClientCertClientBuilder( + 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 `TokenClientBuilder applies read timeout`() { + val client = TokenClientBuilder("https://api.example.com:6443", "test-token", tlsContext) // 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", "", tlsContext) + .build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `LinkClientBuilder falls back when no kubeconfig files exist`() { + val configUtils = mockk() + every { configUtils.getAllConfigFiles() } returns emptyList() + + runCatching { LinkClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 0) { configUtils.getAllConfigs(any()) } + } + + @Test + fun `LinkClientBuilder 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 { LinkClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 1) { configUtils.getAllConfigs(listOf(configPath)) } + verify(exactly = 0) { configUtils.mergeConfigs(any()) } + } + + @Test + fun `LinkClientBuilder 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 = LinkClientBuilder(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() + } +} From 3e3a88d6247aee017d588299e6d984f3a436b84c Mon Sep 17 00:00:00 2001 From: Cristian SFERCOCI Date: Mon, 13 Jul 2026 18:16:38 +0200 Subject: [PATCH 3/6] fix: preserve default trust roots for OpenShift TLS Composite JVM default CAs with custom trusted certificates when building TLS contexts so merged OpenShift/OAuth trust retains public CA coverage. Author: Cristian SFERCOCI Co-authored-by: Cursor --- .../gateway/auth/tls/SslContextFactory.kt | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) 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 ab47c7d3..f38aebbc 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 @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.auth.tls import java.security.SecureRandom +import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager @@ -56,6 +57,14 @@ object SslContextFactory { } fun fromTrustedCerts(certs: List): TlsContext { + val defaultTrustManager = defaultTrustManager() + if (certs.isEmpty()) { + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { + init(null, arrayOf(defaultTrustManager), SecureRandom()) + } + return TlsContext(sslContext, defaultTrustManager, usesSystemTrust = true) + } + val keyStore = KeyStoreUtils.createEmpty() certs.forEachIndexed { idx, cert -> @@ -71,12 +80,17 @@ object SslContextFactory { ) tmf.init(keyStore) - val trustManager = tmf.trustManagers + val customTrustManager = tmf.trustManagers .filterIsInstance() .first() + val trustManager = CompositeX509TrustManager( + defaultTrustManager, + customTrustManager, + ) + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { - init(null, tmf.trustManagers, SecureRandom()) + init(null, arrayOf(trustManager), SecureRandom()) } return TlsContext(sslContext, trustManager) @@ -97,3 +111,47 @@ object SslContextFactory { } } + +private fun defaultTrustManager(): X509TrustManager { + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as java.security.KeyStore?) + + return tmf.trustManagers + .filterIsInstance() + .first() +} + +private class CompositeX509TrustManager( + private val defaultTrustManager: X509TrustManager, + private val customTrustManager: X509TrustManager, +) : X509TrustManager { + + override fun checkClientTrusted(chain: Array, authType: String) { + checkTrusted( + { defaultTrustManager.checkClientTrusted(chain, authType) }, + { customTrustManager.checkClientTrusted(chain, authType) }, + ) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + checkTrusted( + { defaultTrustManager.checkServerTrusted(chain, authType) }, + { customTrustManager.checkServerTrusted(chain, authType) }, + ) + } + + override fun getAcceptedIssuers(): Array = + defaultTrustManager.acceptedIssuers + customTrustManager.acceptedIssuers + + private fun checkTrusted(defaultCheck: () -> Unit, customCheck: () -> Unit) { + try { + defaultCheck() + } catch (defaultFailure: CertificateException) { + try { + customCheck() + } catch (_: CertificateException) { + throw defaultFailure + } + } + } +} From f8523b9a914e8d526cad5845bf79c766149cb744 Mon Sep 17 00:00:00 2001 From: Cristian SFERCOCI Date: Mon, 13 Jul 2026 18:16:38 +0200 Subject: [PATCH 4/6] fix: validate OpenShift auth with SelfSubjectAccessReview Use SelfSubjectAccessReview instead of listing namespaces to check authentication, avoiding broader permissions during OAuth validation. Author: Cristian SFERCOCI Co-authored-by: Cursor --- .../devtools/gateway/openshift/Projects.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt index d4933291..8cb94338 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt @@ -15,7 +15,6 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi -import io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.apis.AuthorizationV1Api import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReview import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReviewSpec @@ -46,8 +45,19 @@ class Projects(private val client: ApiClient) { @Throws(ApiException::class) fun isAuthenticated(): Boolean { return try { - CoreV1Api(client).listNamespace().execute() - true // 200 OK - authenticated and authorized + AuthorizationV1Api(client).createSelfSubjectAccessReview( + V1SelfSubjectAccessReview() + .spec( + V1SelfSubjectAccessReviewSpec() + .resourceAttributes( + V1ResourceAttributes() + .group("project.openshift.io") + .resource("projects") + .verb("list") + ) + ) + ).execute() + true // 201 Created - authenticated; access result is not relevant here } catch (e: ApiException) { when (e.code) { 401 -> false // Unauthorized - not authenticated From 5ff6bef16d7f3b1385e3e9cca23db6817c3c8a6d Mon Sep 17 00:00:00 2001 From: Cristian SFERCOCI Date: Mon, 13 Jul 2026 18:16:38 +0200 Subject: [PATCH 5/6] fix: complete connection when remote IDE is already present Complete immediately when the thin client is already running before listeners attach, and fail with a timeout if IDE readiness never arrives. Author: Cristian SFERCOCI Co-authored-by: Cursor --- .../gateway/DevSpacesConnectionProvider.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 08948051..1a5e1bd3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -69,6 +69,14 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { val thinClient = handle.clientHandle ?: throw RuntimeException("Failed to obtain ThinClientHandle") + if (thinClient.clientPresent) { + indicator.text = "Workspace IDE has started successfully" + indicator.text2 = "Opening project window…" + runDelayed(1000) { if (indicator.isRunning) indicator.stop() } + cont.resume(handle) + return@runProcessWithProgressSynchronously + } + indicator.text = "Waiting for workspace IDE to start..." val ready = CompletableDeferred() @@ -89,6 +97,17 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { cont.resumeWith(Result.failure(error)) } } + + runBlocking { + withTimeoutOrNull(60_000L) { ready.await() } ?: run { + if (ready.isActive) { + indicator.text = "Workspace IDE did not report readiness in time." + ready.completeExceptionally( + RuntimeException("Workspace IDE did not report readiness in time.") + ) + } + } + } } catch (e: Exception) { DevSpacesConnectionProviderErrors.showDialog(e, ctx, indicator) runDelayed(2000) { if (indicator.isRunning) indicator.stop() } From d233bab53fcae9da3957bb4863f37c020153e335 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 13 Jul 2026 19:26:09 +0200 Subject: [PATCH 6/6] test: align Projects and TLS tests with OpenShift OAuth fixes Update ProjectsTest for SelfSubjectAccessReview auth validation and adjust mergeTrustedContext assertions for composite JVM plus custom trust. Co-authored-by: Cursor --- .../tls/DefaultTlsTrustManagerTrustTest.kt | 39 ++++++---- .../auth/tls/TlsTrustManagerTestFixtures.kt | 12 +++ .../gateway/openshift/ProjectsTest.kt | 74 ++++++++----------- 3 files changed, 66 insertions(+), 59 deletions(-) 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 index e78b18fa..787cb46d 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt @@ -17,6 +17,7 @@ import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.OAUTH_UR 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.fixtureCertSerials import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.simulatingTlsProbe import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.successTlsProbe import kotlinx.coroutines.runBlocking @@ -213,6 +214,23 @@ class DefaultTlsTrustManagerTrustTest { } } + @Test + fun `#mergeTrustedContext preserves JVM trust roots alongside custom certificates`() { + runBlocking { + val manager = createManager() + + val tlsContext = manager.mergeTrustedContext( + listOf(API_SERVER_URL), + TlsTestCertificates.caSourceFromData(), + ) + + val trustManager = tlsContext.trustManager as X509TrustManager + assertThat(trustManager.acceptedIssuers.size).isGreaterThan(1) + assertThat(fixtureCertSerials(trustManager, serverCert)) + .containsExactly(serverCert.serialNumber) + } + } + @Test fun `#mergeTrustedContext uses session trust when certificates already accepted`() { runBlocking { @@ -226,11 +244,8 @@ class DefaultTlsTrustManagerTrustTest { TlsTestCertificates.caSourceFromData(), ) - val trustedSerials = (tlsContext.trustManager as X509TrustManager) - .acceptedIssuers - .map { it.serialNumber } - - assertThat(trustedSerials).containsExactly(serverCert.serialNumber) + assertThat(fixtureCertSerials(tlsContext.trustManager as X509TrustManager, serverCert)) + .containsExactly(serverCert.serialNumber) } } @@ -248,11 +263,8 @@ class DefaultTlsTrustManagerTrustTest { certificateAuthority = null, ) - val trustedSerials = (tlsContext.trustManager as X509TrustManager) - .acceptedIssuers - .map { it.serialNumber } - - assertThat(trustedSerials).containsExactly(serverCert.serialNumber) + assertThat(fixtureCertSerials(tlsContext.trustManager as X509TrustManager, serverCert)) + .containsExactly(serverCert.serialNumber) } } @@ -273,11 +285,8 @@ class DefaultTlsTrustManagerTrustTest { TlsTestCertificates.caSourceFromData(), ) - val trustedSerials = (tlsContext.trustManager as X509TrustManager) - .acceptedIssuers - .map { it.serialNumber } - - assertThat(trustedSerials).containsExactly(kubeCa.serialNumber) + assertThat(fixtureCertSerials(tlsContext.trustManager as X509TrustManager, kubeCa)) + .containsExactly(kubeCa.serialNumber) } } } 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 index 14b6da09..54960e36 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManagerTestFixtures.kt @@ -13,11 +13,13 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.KubeConfigTestHelpers import io.kubernetes.client.util.KubeConfig +import java.math.BigInteger import java.net.URI import java.nio.file.Files import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.X509TrustManager object TlsTrustManagerTestFixtures { @@ -74,6 +76,16 @@ object TlsTrustManagerTestFixtures { oauthDiscovery = oauthDiscovery, ) + fun fixtureCertSerials( + trustManager: X509TrustManager, + vararg fixtureCerts: X509Certificate, + ): List { + val fixtureSerials = fixtureCerts.map { it.serialNumber }.toSet() + return trustManager.acceptedIssuers + .map { it.serialNumber } + .filter { it in fixtureSerials } + } + fun successTlsProbe(): (URI, TlsContext) -> Unit = { _, _ -> } fun simulatingTlsProbe( diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ProjectsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ProjectsTest.kt index 6d18f1b7..d6167d1a 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ProjectsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ProjectsTest.kt @@ -13,12 +13,14 @@ package com.redhat.devtools.gateway.openshift import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException -import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.apis.AuthorizationV1Api +import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReview import io.mockk.every import io.mockk.mockk import io.mockk.mockkConstructor import io.mockk.unmockkConstructor 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.BeforeEach import org.junit.jupiter.api.Test @@ -32,76 +34,60 @@ class ProjectsTest { fun beforeEach() { client = mockk(relaxed = true) projects = Projects(client) - mockkConstructor(CoreV1Api::class) + mockkConstructor(AuthorizationV1Api::class) } @AfterEach fun afterEach() { - unmockkConstructor(CoreV1Api::class) + unmockkConstructor(AuthorizationV1Api::class) } @Test fun `#isAuthenticated returns true when API call succeeds`() { - // given - every { - anyConstructed().listNamespace() - } returns mockk { - every { execute() } returns mockk() + stubSelfSubjectAccessReview { + mockk(relaxed = true) } - // when - val result = projects.isAuthenticated() - - // then - assertThat(result).isTrue() + assertThat(projects.isAuthenticated()).isTrue() } @Test fun `#isAuthenticated returns false when API returns 401`() { - // given - every { - anyConstructed().listNamespace() - } returns mockk { - every { execute() } throws ApiException(401, "Unauthorized") - } + stubSelfSubjectAccessReviewFailure(ApiException(401, "Unauthorized")) - // when - val result = projects.isAuthenticated() - - // then - assertThat(result).isFalse() + assertThat(projects.isAuthenticated()).isFalse() } @Test fun `#isAuthenticated returns true when API returns 403`() { - // given - every { - anyConstructed().listNamespace() - } returns mockk { - every { execute() } throws ApiException(403, "Forbidden") - } - - // when - val result = projects.isAuthenticated() + stubSelfSubjectAccessReviewFailure(ApiException(403, "Forbidden")) - // then - assertThat(result).isTrue() + assertThat(projects.isAuthenticated()).isTrue() } @Test fun `#isAuthenticated rethrows ApiException for other error codes`() { - // given + stubSelfSubjectAccessReviewFailure(ApiException(500, "Internal Server Error")) + + assertThatThrownBy { projects.isAuthenticated() } + .isInstanceOf(ApiException::class.java) + .extracting("code") + .isEqualTo(500) + } + + private fun stubSelfSubjectAccessReview(onExecute: () -> V1SelfSubjectAccessReview) { every { - anyConstructed().listNamespace() + anyConstructed().createSelfSubjectAccessReview(any()) } returns mockk { - every { execute() } throws ApiException(500, "Internal Server Error") + every { execute() } answers { onExecute() } } + } - // when/then - org.assertj.core.api.Assertions.assertThatThrownBy { - projects.isAuthenticated() - }.isInstanceOf(ApiException::class.java) - .extracting("code") - .isEqualTo(500) + private fun stubSelfSubjectAccessReviewFailure(exception: ApiException) { + every { + anyConstructed().createSelfSubjectAccessReview(any()) + } returns mockk { + every { execute() } throws exception + } } }