diff --git a/.gitignore b/.gitignore index be3b5f6a..45b23f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ build .DS_Store .intellijPlatform/ .kotlin/ +.vscode/ +.opencode/ +.serena/ diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 013b93e8..194d53ea 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -20,9 +20,9 @@ import com.intellij.ui.dsl.builder.panel import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.devworkspace.DevWorkspaces -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.openshift.DefaultClientBuilder import com.redhat.devtools.gateway.openshift.isNotFound import com.redhat.devtools.gateway.openshift.isUnauthorized import com.redhat.devtools.gateway.util.ProgressCountdown @@ -31,12 +31,7 @@ import com.redhat.devtools.gateway.util.messageWithoutPrefix import com.redhat.devtools.gateway.view.SelectClusterDialog import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import java.util.concurrent.CancellationException import javax.swing.JComponent import javax.swing.Timer @@ -205,8 +200,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { val ctx = DevSpacesContext() indicator.update(message = "Initializing Kubernetes connection…") - val factory = OpenShiftClientFactory(KubeConfigUtils) - ctx.client = factory.create() + ctx.client = DefaultClientBuilder(KubeConfigUtils).build() indicator.update(message = "Fetching workspace “$dwName” from namespace “$dwNamespace”…") ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt new file mode 100644 index 00000000..6b36cf81 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/HttpClientExtensions.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import kotlinx.coroutines.future.await +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +data class AccessTokenResponseJson( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long +) + +suspend fun HttpClient.sendGetRequest( + url: String, + errorPrefix: String = "Request to $url failed" +): HttpResponse { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + val response = sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") + } + return response +} + +suspend fun HttpClient.sendPostRequest( + url: String, + authHeader: String, + formBody: String, + errorPrefix: String = "Request to $url failed" +): AccessTokenResponseJson { + val request = HttpRequest.newBuilder() + .uri(URI(url)) + .header("Authorization", authHeader) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build() + val response = sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("$errorPrefix: ${response.statusCode()}\n${response.body()}") + } + return json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt new file mode 100644 index 00000000..6706f07e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OAuthDiscovery.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.util.toServerBaseUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import javax.net.ssl.SSLContext + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +data class OAuthMetadata( + val issuer: String, + @SerialName("authorization_endpoint") + val authorizationEndpoint: String, + @SerialName("token_endpoint") + val tokenEndpoint: String +) + +class OAuthDiscovery( + apiServerUrl: String, + sslContext: SSLContext, + private val client: HttpClient = HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() +) { + + private val discoveryUrl = "$apiServerUrl/.well-known/oauth-authorization-server" + + suspend fun discoverOAuthMetadata(): OAuthMetadata { + val response = client.sendGetRequest(discoveryUrl) + return json.decodeFromString(OAuthMetadata.serializer(), response.body()) + } + + suspend fun endpointBaseUrls(): List { + thisLogger().info("TLS trust: discovering OAuth endpoints from $discoveryUrl") + val md = try { + discoverOAuthMetadata() + } catch (e: Exception) { + thisLogger().error("TLS trust: OAuth discovery request to $discoveryUrl failed", e) + throw e + } + val urls = listOf(md.tokenEndpoint, md.authorizationEndpoint) + .map { URI(it).toServerBaseUrl() } + .distinct() + thisLogger().info( + "TLS trust: OAuth discovery succeeded (issuer=${md.issuer}, " + + "endpoints=${urls.joinToString()})" + ) + return urls + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index 8293d4c2..ead18fbb 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -18,11 +18,10 @@ 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.logger +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 +39,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 +48,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 +64,8 @@ class OpenShiftAuthCodeFlow( .build() } - @Serializable - private data class OAuthMetadata( - val issuer: String, - - @SerialName("authorization_endpoint") - val authorizationEndpoint: String, - - @SerialName("token_endpoint") - val tokenEndpoint: String - ) - - /** - * 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 +85,10 @@ 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 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 +97,50 @@ class OpenShiftAuthCodeFlow( URLEncoder.encode(v, StandardCharsets.UTF_8) } - private suspend fun exchangeCodeForToken(code: String): SSOToken { - val httpClient = discoveryClient + private fun parseRedirectQuery(location: String): Map { + val query = URI(location).query ?: error("Missing query in redirect") + return query.split("&") + .map { it.split("=", limit = 2) } + .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + } - val basicAuth = "Basic " + Base64.getEncoder() - .encodeToString("openshift-cli-client:".toByteArray(StandardCharsets.UTF_8)) + private suspend fun exchangeCodeForToken( + code: String, + client: HttpClient, + clientId: String, + redirectUri: URI, + clientIdInForm: Boolean = true, + accountLabel: String = "", + ): SSOToken { + val authHeader = "Basic " + Base64.getEncoder() + .encodeToString("$clientId:".toByteArray(StandardCharsets.UTF_8)) val form = encodeForm( "grant_type" to "authorization_code", - "client_id" to "openshift-cli-client", "code" to code, + "code_verifier" to codeVerifier.value, "redirect_uri" to redirectUri.toString(), - "code_verifier" to codeVerifier.value + *if (clientIdInForm) arrayOf("client_id" to clientId) else emptyArray() ) - val request = HttpRequest.newBuilder() - .uri(URI(metadata.tokenEndpoint)) - .header("Authorization", basicAuth) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() - - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() !in 200..299) { - error("Token request failed: ${response.statusCode()}\n${response.body()}") + val token = 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 - ) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + return SSOToken(accessToken = token.accessToken, idToken = "", accountLabel = accountLabel, expiresAt = expiresAt) } override suspend fun login(parameters: Parameters): SSOToken { 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,42 +160,15 @@ class OpenShiftAuthCodeFlow( val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) - // First request (expect 401) - var request = HttpRequest.newBuilder() - .uri(authorizeUri) - .header("X-Csrf-Token", "1") - .GET() - .build() - - var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - - // Retry with Basic auth - if (response.statusCode() == 401) { - request = HttpRequest.newBuilder() - .uri(authorizeUri) - .header("Authorization", basicAuth) - .header("X-Csrf-Token", "1") - .GET() - .build() - - response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - } - - if (response.statusCode() !in listOf(302, 303)) { - error("Authorization failed: ${response.statusCode()}") - } + val response = sendWithRetryOn401(noRedirectClient, authorizeUri, basicAuth) val location = response.headers().firstValue("Location") .orElseThrow { error("Missing redirect Location header") } - val redirectedUri = URI(location) - val query = redirectedUri.query ?: error("Missing query in redirect") - val params = query.split("&") - .map { it.split("=", limit = 2) } - .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + val params = parseRedirectQuery(location) val code = params["code"] ?: error("Authorization code not found in redirect") - val token = exchangeCodeForTokenWithBasicAuth(httpClient, code = code, redirectUri = redirectUri) + val token = exchangeCodeForToken(code, noRedirectClient, "openshift-challenging-client", redirectUri, clientIdInForm = false) return SSOToken( accessToken = token.accessToken, @@ -248,45 +178,34 @@ class OpenShiftAuthCodeFlow( ) } - private suspend fun exchangeCodeForTokenWithBasicAuth( - httpClient: HttpClient, - code: String, - redirectUri: URI - ): SSOToken { - val clientAuth = "Basic " + Base64.getEncoder() - .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) + private suspend fun sendWithRetryOn401( + client: HttpClient, + authorizeUri: URI, + basicAuth: String + ): HttpResponse { + var request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("X-Csrf-Token", "1") + .GET() + .build() - val form = encodeForm( - "grant_type" to "authorization_code", - "code" to code, - "redirect_uri" to redirectUri.toString(), - "code_verifier" to codeVerifier.value - ) + var response = client.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() - val request = HttpRequest.newBuilder() - .uri(URI(metadata.tokenEndpoint)) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Authorization", clientAuth) - .POST(HttpRequest.BodyPublishers.ofString(form)) - .build() + if (response.statusCode() == 401) { + request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("Authorization", basicAuth) + .header("X-Csrf-Token", "1") + .GET() + .build() - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() - if (response.statusCode() != 200) { - error("Token exchange failed: ${response.statusCode()} ${response.body()}") + response = client.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() } - val token = json.decodeFromString( - AccessTokenResponseJson.serializer(), - response.body() - ) - val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + if (response.statusCode() !in listOf(302, 303)) { + error("Authorization failed: ${response.statusCode()}") + } - return SSOToken( - accessToken = token.accessToken, - idToken = "", - accountLabel = "", - expiresAt = expiresAt - ) + return response } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt index 012e9af6..8983443d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt @@ -14,9 +14,7 @@ package com.redhat.devtools.gateway.auth.sandbox import com.redhat.devtools.gateway.auth.code.AuthTokenKind import com.redhat.devtools.gateway.auth.code.SSOToken import com.redhat.devtools.gateway.auth.code.TokenModel -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory -import io.kubernetes.client.openapi.ApiClient +import com.redhat.devtools.gateway.openshift.TokenClientBuilder import io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.models.V1ObjectMeta import io.kubernetes.client.openapi.models.V1Secret @@ -29,8 +27,7 @@ class SandboxClusterAuthProvider( private val sandboxApi: SandboxApi = SandboxApi( SandboxDefaults.SANDBOX_API_BASE_URL, SandboxDefaults.SANDBOX_API_TIMEOUT_MS - ), - private val clientFactory: OpenShiftClientFactory = OpenShiftClientFactory(KubeConfigUtils) + ) ) { suspend fun authenticate(ssoToken: SSOToken): TokenModel { val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) @@ -41,8 +38,7 @@ class SandboxClusterAuthProvider( val username = signup.compliantUsername ?: signup.username val namespace = "$username-dev" - val client = clientFactory - .builder(signup.proxyUrl!!, ssoToken.idToken) + val client = TokenClientBuilder(signup.proxyUrl!!, ssoToken.idToken) .readTimeout(30, TimeUnit.SECONDS) .build() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 6b0a632b..bc22d9c3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -11,9 +11,14 @@ */ 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.SSLHandshakeException @@ -25,48 +30,56 @@ class DefaultTlsTrustManager( private val persistentKeyStore: PersistentKeyStore ) : TlsTrustManager { + private val logger = thisLogger() + override suspend fun ensureTrusted( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource?, + endpointKind: TlsEndpointKind, ): TlsContext { val serverUri = URI(serverUrl) + logger.info( + "TLS trust: probing ${endpointKind.label} at $serverUrl " + + "(wizard CA=${certificateAuthority != null}, kind=$endpointKind)" + ) val namedCluster = - KubeConfigTlsUtils.findClusterByServer( + KubeConfigUtils.getClusterByServer( serverUrl, kubeConfigProvider() ) if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { + logger.warn("TLS trust: using insecure skip for $serverUrl (kubeconfig insecure-skip-tls-verify)") return SslContextFactory.insecure() } - val trustedCerts = mutableListOf() - namedCluster?.let { - trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) - } - - trustedCerts += sessionTrustStore.get(serverUrl) - - val keyStore = persistentKeyStore.loadOrCreate() - val persistentAlias = "host:${serverUri.host}" - - val persistentCert = keyStore.getCertificate(persistentAlias) - if (persistentCert is X509Certificate) { - trustedCerts += persistentCert - } + val trustedCerts = getTrustedCerts(namedCluster, serverUri.host, certificateAuthority) + + sessionTrustStore.get(serverUrl) if (trustedCerts.isNotEmpty()) { + logger.debug( + "TLS trust: trying ${trustedCerts.size} known certificate(s) for $serverUrl " + + "(session=${sessionTrustStore.get(serverUrl).size}, " + + "preconfigured=${trustedCerts.size - sessionTrustStore.get(serverUrl).size})" + ) try { val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) withContext(Dispatchers.IO) { TlsProbe.connect(serverUri, tlsContext.sslContext) } + logger.info("TLS trust: existing trust accepted for $serverUrl") return tlsContext } catch (e: SSLHandshakeException) { - // Certificate changed or invalid → continue to capture + logger.warn( + "TLS trust: handshake failed with known certificate(s) for $serverUrl; " + + "will prompt (${e.message})" + ) } + } else { + logger.info("TLS trust: no known certificate for $serverUrl; will capture server certificate") } val captureContext = SslContextFactory.captureOnly() @@ -75,6 +88,7 @@ class DefaultTlsTrustManager( withContext(Dispatchers.IO) { TlsProbe.connect(serverUri, captureContext.sslContext) } + logger.warn("TLS trust: probe unexpectedly succeeded without trust for $serverUrl") return captureContext // should not normally succeed } catch (e: SSLHandshakeException) { val chain = (captureContext.trustManager as? CapturingTrustManager) @@ -94,14 +108,29 @@ class DefaultTlsTrustManager( serverUrl = serverUrl, certificateChain = chain, fingerprintSha256 = sha256Fingerprint(trustAnchor), - problem = problem + problem = problem, + endpointKind = endpointKind, + ) + + logger.info( + "TLS trust: prompting user for ${endpointKind.label} at $serverUrl " + + "(problem=$problem, fingerprint=${info.fingerprintSha256})" ) val decision = decisionHandler(info) if (!decision.trusted) { + logger.info("TLS trust: user rejected certificate for $serverUrl") throw TlsTrustRejectedException() } + logger.info( + "TLS trust: user accepted certificate for $serverUrl " + + "(scope=${decision.scope}, endpoint=${endpointKind.label})" + ) + + val keyStore = persistentKeyStore.loadOrCreate() + val persistentAlias = hostAlias(serverUri.host) + when (decision.scope) { TlsTrustScope.SESSION_ONLY -> { sessionTrustStore.put(serverUrl, listOf(trustAnchor)) @@ -127,11 +156,148 @@ class DefaultTlsTrustManager( val finalCerts = (trustedCerts + trustAnchor) .distinctBy { it.serialNumber } - return SslContextFactory.fromTrustedCerts(finalCerts) + val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts) + withContext(Dispatchers.IO) { + TlsProbe.connect(serverUri, tlsContext.sslContext) + } + logger.info("TLS trust: verified connection to $serverUrl after user acceptance") + return tlsContext } } - /** Private helper: SHA-256 fingerprint of a certificate */ + /** + * Resolves TLS trust for the API server and OAuth endpoints discovered from it. + */ + suspend fun ensureOpenShiftTlsContext( + apiServerUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, + ): TlsContext { + val apiBaseUrl = URI(apiServerUrl).toServerBaseUrl() + logger.info("TLS trust: establishing OpenShift TLS context for API $apiBaseUrl") + + ensureTrusted( + apiBaseUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.API_SERVER, + ) + + val apiTls = mergedContextFor(listOf(apiBaseUrl), certificateAuthority) + val oauthUrls = try { + OAuthDiscovery(apiBaseUrl, apiTls.sslContext).endpointBaseUrls() + } catch (e: Exception) { + logger.error( + "TLS trust: failed to discover OAuth endpoints from $apiBaseUrl. " + + "Login may fail if the OAuth host uses a different certificate.", + e + ) + throw e + } + + if (oauthUrls.isEmpty()) { + logger.warn( + "TLS trust: OAuth discovery returned no endpoints for $apiBaseUrl. " + + "Only the API server certificate will be trusted." + ) + } else { + logger.info("TLS trust: discovered OAuth endpoint host(s): ${oauthUrls.joinToString()}") + } + + val allUrls = (listOf(apiBaseUrl) + oauthUrls).distinct() + for (url in allUrls) { + if (url != apiBaseUrl) { + ensureTrusted( + url, + decisionHandler, + certificateAuthority, + TlsEndpointKind.OAUTH, + ) + } + } + + val merged = mergedContextFor(allUrls, certificateAuthority) + logger.info( + "TLS trust: OpenShift TLS context ready for ${allUrls.size} endpoint(s): " + + allUrls.joinToString() + ) + return merged + } + + suspend fun mergedContextFor( + serverUrls: Collection, + certificateAuthority: CertificateSource? = null, + ): TlsContext { + val configs = kubeConfigProvider() + val keyStore = persistentKeyStore.loadOrCreate() + val allCerts = mutableListOf() + val sessionCerts = sessionTrustStore.allCertificates() + + for (serverUrl in serverUrls.distinct()) { + val uri = URI(serverUrl) + if (sessionCerts.isEmpty()) { + certificateAuthority?.let { + allCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } + KubeConfigUtils.getClusterByServer(serverUrl, configs)?.let { + allCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } + val persistentCert = keyStore.getCertificate(hostAlias(uri.host)) + if (persistentCert is X509Certificate) { + allCerts += persistentCert + } + } + allCerts += sessionTrustStore.get(serverUrl) + allCerts += sessionCerts + } + + require(allCerts.isNotEmpty()) { "No trusted certificates for: $serverUrls" } + return SslContextFactory.fromTrustedCerts(allCerts.distinctBy { it.serialNumber }) + } + + /** + * Returns the list of trusted X.509 certificates for a server URL. + * + *

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

+ * + * @param namedCluster The optional Kubernetes cluster configuration from kubeconfig + * @param host The hostname to look up in the persistent keystore (without scheme) + * @return List of X.509 certificates to trust for TLS verification + */ + private fun getTrustedCerts( + namedCluster: KubeConfigNamedCluster?, + host: String, + certificateAuthority: CertificateSource?, + ): List { + val sessionCerts = sessionTrustStore.allCertificates() + + return buildList { + if (sessionCerts.isEmpty()) { + certificateAuthority?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + namedCluster?.let { + addAll(KubeConfigTlsUtils.extractCaCertificates(it)) + } + val persistentCert = persistentKeyStore.loadOrCreate() + .getCertificate(hostAlias(host)) + if (persistentCert is X509Certificate) { + add(persistentCert) + } + } else { + sessionCerts.forEach { cert -> + if (cert !in this) { + add(cert) + } + } + } + } + } + + private fun hostAlias(host: String) = "host:$host" + private fun sha256Fingerprint(cert: X509Certificate): String { val digest = java.security.MessageDigest.getInstance("SHA-256") .digest(cert.encoded) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt index 8d31a407..04cf43aa 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Red Hat, Inc. + * Copyright (c) 2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster -import io.kubernetes.client.util.KubeConfig import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 @@ -21,23 +20,22 @@ import kotlin.io.path.readText object KubeConfigTlsUtils { - fun findClusterByServer( - serverUrl: String, - kubeConfigs: List - ): KubeConfigNamedCluster? = - kubeConfigs - .flatMap { it.clusters ?: emptyList() } - .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } - .firstOrNull { it.cluster.server == serverUrl } - fun extractCaCertificates( namedCluster: KubeConfigNamedCluster - ): List { - val caSource = namedCluster.cluster.certificateAuthority ?: return emptyList() - val caContent = if (caSource.isFilePath) { - caSource.toPath().readText() - } else { - Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + ): List = + namedCluster.cluster.certificateAuthority + ?.let(::extractCaCertificates) + .orEmpty() + + fun extractCaCertificates(caSource: CertificateSource): List { + val caContent = try { + if (caSource.isFilePath) { + caSource.toPath().readText() + } else { + Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + } + } catch (_: Exception) { + return emptyList() } val factory = CertificateFactory.getInstance("X.509") diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt index 18a1e4c9..511b8686 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt @@ -24,4 +24,8 @@ class SessionTlsTrustStore { fun put(serverUrl: String, certificates: List) { trusted[serverUrl] = certificates } + + /** All certificates accepted in this wizard session, across every server URL. */ + fun allCertificates(): List = + trusted.values.flatten().distinctBy { it.serialNumber } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/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..b11aed44 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt @@ -21,6 +21,8 @@ interface TlsTrustManager { */ suspend fun ensureTrusted( serverUrl: String, - decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision, + certificateAuthority: CertificateSource? = null, + endpointKind: TlsEndpointKind = TlsEndpointKind.UNKNOWN, ): TlsContext } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt new file mode 100644 index 00000000..1fe38995 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionDialog.kt @@ -0,0 +1,147 @@ +/* + * 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(), true) { + + companion object { + val PREFERRED_SIZE = Dimension(600, 400) + } + + /** Will be true if user chose to persist the trust decision. */ + var rememberDecision: Boolean = false + private set + + /** Will be true if user trusted the certificate (permanent or session). */ + var isTrusted: Boolean = false + private set + + init { + title = "Untrusted TLS Certificate — ${endpointKind.label}" + init() + } + + override fun createCenterPanel(): JComponent { + val panel = JPanel(BorderLayout(16, 16)).apply { + border = JBUI.Borders.empty(JBUI.scale(8)) + } + + val wrappedUrl = serverUrl.chunked(40).joinToString("\u200B") + val htmlText = """ + + + + + + The ${endpointKind.label} at $wrappedUrl presents a TLS certificate that is not trusted. +
+ You can choose to trust it permanently, trust it for this session only, or cancel the connection. + + + """.trimIndent() + + val messagePane = object : JEditorPane("text/html", htmlText) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(PREFERRED_SIZE.width, size.height) + } + }.apply { + isEditable = false + isOpaque = false + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + font = UIManager.getFont("Label.font") + addHyperlinkListener(object : HyperlinkAdapter() { + override fun hyperlinkActivated(e: HyperlinkEvent) { + BrowserUtil.browse(e.url) + } + }) + } + panel.add(messagePane, BorderLayout.NORTH) + + val certArea = JBTextArea(certificateInfo).apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + border = BorderFactory.createEmptyBorder() + } + + val scrollPane = JBScrollPane(certArea).apply { + preferredSize = PREFERRED_SIZE + setBorder(null) + setViewportBorder(null) + } + + panel.add(scrollPane, BorderLayout.CENTER) + + return panel + } + + override fun createActions(): Array { + return arrayOf( + object : DialogWrapperAction("Trust Permanently") { + override fun doAction(e: ActionEvent) { + isTrusted = true + rememberDecision = true + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Trust for This Session Only") { + override fun doAction(e: ActionEvent) { + isTrusted = true + rememberDecision = false + close(OK_EXIT_CODE) + } + }, + object : DialogWrapperAction("Cancel") { + override fun doAction(e: ActionEvent) { + isTrusted = false + rememberDecision = false + close(CANCEL_EXIT_CODE) + } + } + ) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt deleted file mode 100644 index c59a0e6f..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright (c) 2025-2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.auth.tls.ui - -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.components.JBTextArea -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.event.ActionEvent -import javax.swing.Action -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.JScrollPane - -/** - * Dialog that asks the user to trust a TLS certificate from a server. - * - * @param serverUrl The URL of the server presenting the certificate. - * @param certificateInfo PEM/text representation of the certificate. - */ -class TLSTrustDecisionHandler( - private val serverUrl: String, - private val certificateInfo: String -) : DialogWrapper(true) { - - /** Will be true if user chose to persist the trust decision. */ - var rememberDecision: Boolean = false - private set - - /** Will be true if user trusted the certificate (permanent or session). */ - var isTrusted: Boolean = false - private set - - init { - title = "Untrusted TLS Certificate" - init() - } - - override fun createCenterPanel(): JComponent { - val panel = JPanel(BorderLayout(8, 8)) - - val message = JBTextArea( - "The server at $serverUrl presents a TLS certificate that is not trusted.\n" + - "You can choose to trust it permanently, trust it for this session only, or cancel the connection." - ) - message.isEditable = false - message.isOpaque = false - message.lineWrap = true - message.wrapStyleWord = true - - val certArea = JBTextArea(certificateInfo).apply { - isEditable = false - lineWrap = false - font = message.font - } - - val scrollPane = JScrollPane(certArea).apply { - preferredSize = Dimension(600, 200) - } - - panel.add(message, BorderLayout.NORTH) - panel.add(scrollPane, BorderLayout.CENTER) - - return panel - } - - override fun createActions(): Array { - return arrayOf( - object : DialogWrapperAction("Trust Permanently") { - override fun doAction(e: ActionEvent) { - isTrusted = true - rememberDecision = true - close(OK_EXIT_CODE) - } - }, - object : DialogWrapperAction("Trust for This Session Only") { - override fun doAction(e: ActionEvent) { - isTrusted = true - rememberDecision = false - close(OK_EXIT_CODE) - } - }, - object : DialogWrapperAction("Cancel") { - override fun doAction(e: ActionEvent) { - isTrusted = false - rememberDecision = false - close(CANCEL_EXIT_CODE) - } - } - ) - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt new file mode 100644 index 00000000..7de46fa0 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UITlsDecisionAdapter.kt @@ -0,0 +1,73 @@ +/* + * 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 + +object UITlsDecisionAdapter { + + private val logger = thisLogger() + + /** + * @param parent optional parent Component for the trust dialog; if null, the dialog is + * centered on screen (no parent). The caller is responsible for ensuring + * the value is safe to use on the EDT (e.g. captured on the EDT before a + * background thread runs). + */ + suspend fun decide(info: TlsServerCertificateInfo, parent: Component? = null): TlsTrustDecision { + logger.info( + "TLS trust: showing trust dialog for ${info.endpointKind.label} at ${info.serverUrl} " + + "(parent=${parent?.javaClass?.simpleName ?: "none"})" + ) + + lateinit var dialog: TLSTrustDecisionDialog + + // invokeAndWait is required here: trust runs on a progress worker thread while the EDT + // is blocked by runProcessWithProgressSynchronously. invokeLater would queue the dialog + // on the EDT and never run it. ModalityState.any() allows the dialog above the progress UI. + ApplicationManager.getApplication().invokeAndWait( + { + dialog = TLSTrustDecisionDialog( + parent = parent, + serverUrl = info.serverUrl, + endpointKind = info.endpointKind, + certificateInfo = PemUtils.toPem(info.certificateChain.first()), + ) + dialog.show() + }, + ModalityState.any(), + ) + + return when { + !dialog.isTrusted -> { + logger.info("TLS trust: user cancelled dialog for ${info.serverUrl}") + TlsTrustDecision.reject() + } + + dialog.rememberDecision -> { + logger.info("TLS trust: user chose permanent trust for ${info.serverUrl}") + TlsTrustDecision.permanent() + } + + else -> { + logger.info("TLS trust: user chose session-only trust for ${info.serverUrl}") + TlsTrustDecision.sessionOnly() + } + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt deleted file mode 100644 index 0298bd01..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2025-2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.auth.tls.ui - -import com.intellij.openapi.application.ApplicationManager -import com.redhat.devtools.gateway.auth.tls.* - -object UiTlsDecisionAdapter { - - suspend fun decide(info: TlsServerCertificateInfo): TlsTrustDecision { - lateinit var dialog: TLSTrustDecisionHandler - - ApplicationManager.getApplication().invokeAndWait { - dialog = TLSTrustDecisionHandler( - serverUrl = info.serverUrl, - certificateInfo = PemUtils.toPem(info.certificateChain.first()) - ) - dialog.show() - } - - return when { - !dialog.isTrusted -> - TlsTrustDecision.reject() - - dialog.rememberDecision -> - TlsTrustDecision.permanent() - - else -> - TlsTrustDecision.sessionOnly() - } - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt index 87d9ab14..024a8076 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -34,6 +34,15 @@ object KubeConfigUtils { return currentUser?.user?.token != null } + fun getClusterByServer( + serverUrl: String, + kubeConfigs: List + ): KubeConfigNamedCluster? = + kubeConfigs + .flatMap { it.clusters ?: emptyList() } + .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } + .firstOrNull { it.cluster.server == serverUrl } + fun getClusters(kubeconfigPaths: List): List { logger.info("Getting clusters from kubeconfig paths: $kubeconfigPaths") val kubeConfigs = toKubeConfigs(kubeconfigPaths) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt new file mode 100644 index 00000000..3f4b47e0 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilder.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.util.Config +import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import okhttp3.OkHttpClient +import okhttp3.Protocol +import java.io.IOException +import java.security.KeyStore +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import kotlin.io.path.readText + +/** + * Interface for building OpenShift API clients. + */ +interface OpenShiftClientBuilder { + fun build(): ApiClient + fun readTimeout(timeout: Long, unit: TimeUnit): OpenShiftClientBuilder +} + +/** + * Base class for building OpenShift API clients. + * Provides shared read timeout handling via the [applyReadTimeout] helper. + */ +abstract class BaseClientBuilder : OpenShiftClientBuilder { + private var readTimeoutSeconds: Long = 0 + + override fun readTimeout(timeout: Long, unit: TimeUnit): OpenShiftClientBuilder { + this.readTimeoutSeconds = unit.toSeconds(timeout) + return this + } + + protected fun applyReadTimeout(client: ApiClient): ApiClient { + if (readTimeoutSeconds > 0) { + client.httpClient = client.httpClient.newBuilder() + .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) + .build() + } + return client + } +} + +/** + * Builder for default API clients (no server/token specified). + * Reads kubeconfig files and creates an ApiClient from them. + */ +class DefaultClientBuilder( + private val configUtils: KubeConfigUtils +) : BaseClientBuilder() { + override fun build(): ApiClient { + val paths = configUtils.getAllConfigFiles() + if (paths.isEmpty()) { + thisLogger().debug("No effective kubeconfig found. Falling back to default ApiClient.") + return ClientBuilder.defaultClient() + } + + return try { + val allConfigs = configUtils.getAllConfigs(paths) + if (allConfigs.isEmpty()) { + thisLogger().debug("No valid kubeconfig content found. Falling back to default ApiClient.") + return ClientBuilder.defaultClient() + } + + val kubeConfig = configUtils.mergeConfigs(allConfigs) + val client = ClientBuilder.kubeconfig(kubeConfig).build() + applyReadTimeout(client) + } catch (e: Exception) { + thisLogger().debug( + "Failed to build effective Kube config from discovered files due to error: ${e.message}. " + + "Falling back to the default ApiClient." + ) + ClientBuilder.defaultClient() + } + } +} + +/** + * Builder for token-authenticated API clients. + * Creates a kubeconfig from the provided server and token, then builds an ApiClient. + */ +class TokenClientBuilder( + private val server: String, + private val token: String +) : BaseClientBuilder() { + override fun build(): ApiClient { + val kubeConfig = createKubeConfig(server, null, token.toCharArray(), null, null) + val client = Config.fromConfig(kubeConfig) + return applyReadTimeout(client) + } +} + +/** + * Builder for TLS-authenticated API clients. + * Handles both token-based and client-certificate authentication with TlsContext. + */ +class TlsClientBuilder( + private val server: String, + private val token: String? = null, + private val clientCert: CertificateSource? = null, + private val clientKey: CertificateSource? = null, + private val tlsContext: TlsContext +) : BaseClientBuilder() { + override fun build(): ApiClient { + validateAuthInputs() + return if (clientCert != null && clientKey != null) { + createWithClientCertFromTls(server, clientCert, clientKey, tlsContext) + } else { + createWithTokenFromTls(server, token!!, tlsContext) + } + } + + private fun validateAuthInputs() { + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + } + + /** + * Builds a client-certificate-authenticated client using the same [TlsContext] SSL stack as OAuth. + */ + internal fun createWithClientCertFromTls( + server: String, + clientCert: CertificateSource, + clientKey: CertificateSource, + tlsContext: TlsContext + ): ApiClient { + val trustManager = tlsContext.trustManager + val sslContext = createSSLContext(trustManager, true, clientCert, clientKey) + val client = ApiClient(createHttpClient(sslContext, trustManager)) + client.basePath = normalizeBasePath(server) + return applyReadTimeout(client) + } + + private fun createSSLContext( + trustManager: X509TrustManager, + usingClientCert: Boolean, + clientCert: CertificateSource?, + clientKey: CertificateSource? + ): SSLContext { + val keyManagers: Array? = + if (usingClientCert && clientCert != null && clientKey != null) { + createKeyManagers(clientCert, clientKey) + } else { + null + } + + return SSLContext.getInstance("TLS").apply { + init( + keyManagers, + arrayOf(trustManager), + SecureRandom() + ) + } + } + + private fun createKeyManagers( + certSource: CertificateSource, + keySource: CertificateSource + ): Array { + + val certContent = resolve(certSource) + val keyContent = resolve(keySource) + + val certificate = PemUtils.parseCertificate(certContent) + val privateKey = PemUtils.parsePrivateKey(keyContent) + + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null) + + keyStore.setKeyEntry( + "client", + privateKey, + CharArray(0), + arrayOf(certificate) + ) + + val kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ) + kmf.init(keyStore, CharArray(0)) + + return kmf.keyManagers + } + + /** + * Resolves CertificateSource to actual content. + * If it's a file path, reads the file. Otherwise returns the value. + */ + private fun resolve(source: CertificateSource): String { + return if (source.isFilePath) { + try { + source.toPath().readText() + } catch (e: Exception) { + throw IOException("Failed to read certificate file: ${source.value}", e) + } + } else { + source.value + } + } + + private fun createHttpClient(sslContext: SSLContext, trustManager: X509TrustManager): OkHttpClient { + return OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .callTimeout(DEFAULT_HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + // Match OAuth HttpClient (HTTP/1.1); some clusters hang on HTTP/2. + .protocols(listOf(Protocol.HTTP_1_1)) + .build() + } + + /** + * Builds a token-authenticated client using the same [TlsContext] SSL stack as OAuth. + * Avoids [io.kubernetes.client.util.Config.fromConfig], which applies JVM default trust via [ApiClient.applySslSettings]. + */ + internal fun createWithTokenFromTls(server: String, token: String, tlsContext: TlsContext): ApiClient { + val client = ApiClient(createHttpClient(tlsContext.sslContext, tlsContext.trustManager)) + client.basePath = normalizeBasePath(server) + AccessTokenAuthentication(token.trim()).provide(client) + return applyReadTimeout(client) + } +} + +private const val DEFAULT_HTTP_TIMEOUT_SECONDS = 30L + +private fun normalizeBasePath(server: String): String = server.trim().removeSuffix("/") + + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt deleted file mode 100644 index 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/OpenShiftKubeConfigUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt new file mode 100644 index 00000000..8ce0322f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftKubeConfigUtils.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.openshift + +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import io.kubernetes.client.util.KubeConfig + +private val userName = "openshift_user" +private val contextName = "openshift_context" +private val clusterName = "openshift_cluster" + +/** + * Creates a kubeconfig from the provided parameters. + */ +internal fun createKubeConfig( + server: String, + certificateAuthority: CertificateSource? = null, + token: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null +): KubeConfig { + + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null + + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + + val clusterEntry = createCluster(server, certificateAuthority) + val userEntry = createUser(usingToken, token, clientCert, clientKey) + val contextEntry = mapOf( + "name" to contextName, + "context" to mapOf( + "cluster" to clusterName, + "user" to userName + ) + ) + + val kubeConfig = KubeConfig(arrayListOf(contextEntry), arrayListOf(clusterEntry), arrayListOf(userEntry)) + kubeConfig.setContext(contextName) + + return kubeConfig +} + +private fun createCluster( + server: String, + certificateAuthority: CertificateSource? +): Map { + val cluster = mutableMapOf( + "server" to server.trim() + ) + + certificateAuthority?.let { ca -> + if (ca.isFilePath) { + cluster["certificate-authority"] = ca.value.trim() + } else { + cluster["certificate-authority-data"] = PemUtils.toBase64(ca.value.trim()) + } + } + + val clusterEntry = mapOf( + "name" to clusterName, + "cluster" to cluster + ) + return clusterEntry +} + +private fun createUser(usingToken: Boolean, token: CharArray?, clientCert: CertificateSource?, clientKey: CertificateSource?): Map { + val user = mutableMapOf() + + if (usingToken + && token != null) { + setToken(token, user) + } else { + setClientCertificates(clientCert, clientKey, user) + } + + return mapOf( + "name" to userName, + "user" to user + ) +} + +private fun setToken(token: CharArray, user: MutableMap) { + user["token"] = String(token).trim() +} + +private fun setClientCertificates( + clientCert: CertificateSource?, + clientKey: CertificateSource?, + user: MutableMap +) { + clientCert?.let { cert -> + if (cert.isFilePath) { + user["client-certificate"] = cert.value.trim() + } else { + user["client-certificate-data"] = PemUtils.toBase64(cert.value.trim()) + } + } + clientKey?.let { key -> + if (key.isFilePath) { + user["client-key"] = key.value.trim() + } else { + user["client-key-data"] = PemUtils.toBase64(key.value.trim()) + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index cf371292..dfa37d89 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -27,3 +27,14 @@ fun Throwable.isCancellationException(): Boolean = (this is CancellationExceptio fun Throwable.isLoginUserCancelled(): Boolean = generateSequence(this) { it.cause }.any { it is SsoLoginException.Cancelled } + +fun Throwable.isTlsRelated(): Boolean = + generateSequence(this) { it.cause }.any { throwable -> + val message = throwable.message.orEmpty() + val className = throwable::class.java.name + className.contains("SSL", ignoreCase = true) || + className.contains("Tls", ignoreCase = true) || + message.contains("PKIX", ignoreCase = true) || + message.contains("certificate", ignoreCase = true) || + message.contains("handshake", ignoreCase = true) + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt index 03e4dbd1..8b36e876 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt @@ -11,4 +11,12 @@ */ package com.redhat.devtools.gateway.util +import java.net.URI + fun String.stripScheme(): String = substringAfter("://", this) + +/** Returns the scheme/host/port base URL used for TLS trust lookups. */ +fun URI.toServerBaseUrl(): String { + val port = port + return if (port > 0) "$scheme://$host:$port" else "$scheme://$host" +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 8397f9e3..bf901321 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.PathManager import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.ui.MessageDialogBuilder @@ -31,7 +32,7 @@ import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.* -import com.redhat.devtools.gateway.auth.tls.ui.UiTlsDecisionAdapter +import com.redhat.devtools.gateway.auth.tls.ui.UITlsDecisionAdapter import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate @@ -44,6 +45,7 @@ import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu import com.redhat.devtools.gateway.view.ui.requestInitialFocus import com.redhat.devtools.gateway.util.isLoginUserCancelled +import com.redhat.devtools.gateway.util.isTlsRelated import com.redhat.devtools.gateway.util.stripScheme import kotlinx.coroutines.* import java.awt.event.ItemEvent @@ -116,8 +118,8 @@ class DevSpacesServerStepView( ::createEnterKeyListener ) - val setTokenDisplay: suspend (String) -> Unit = { token -> - withContext(Dispatchers.Main) { + val setTokenDisplay: (String) -> Unit = { token -> + ApplicationManager.getApplication().invokeLater { tokenStrategy.tfToken.text = token } } @@ -127,7 +129,7 @@ class DevSpacesServerStepView( OpenShiftOAuthAuthenticationStrategy( tfServer, ::saveKubeconfig, - setTokenDisplay + setTokenDisplay, ), ClientCertificateAuthenticationStrategy( tfServer, @@ -140,7 +142,7 @@ class DevSpacesServerStepView( ::saveKubeconfig, ::onFieldChanged, ::createEnterKeyListener, - setTokenDisplay + setTokenDisplay, ), RedHatSSOAuthenticationStrategy( tfServer, @@ -390,56 +392,103 @@ class DevSpacesServerStepView( onDispose() var authResult: Result? = null + val certificateAuthority = resolveCertificateAuthority(tfCertAuthority.text) + if (certificateAuthority != null) { + thisLogger().info( + "TLS trust: wizard Certificate Authority provided " + + "(file=${certificateAuthority.isFilePath})" + ) + } - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - runBlocking { - val indicator = ProgressManager.getInstance().progressIndicator - indicator.text = "Connecting to cluster..." - - try { - val tlsContext = resolveSslContext(server) - val certAuthorityData = tfCertAuthority.text.ifBlank { null } + var tlsContext: TlsContext? = null - strategy.authenticate( - selectedCluster, + try { + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking { + val indicator = ProgressManager.getInstance().progressIndicator + indicator.text = "Establishing secure connection..." + tlsContext = resolveTlsContext( server, - certAuthorityData, - tlsContext, - devSpacesContext, - indicator + strategy.getAuthMethod(), + certificateAuthority, ) - authResult = Result.success(Unit) - } catch (e: Exception) { - authResult = Result.failure(e) } - } - }, - "Connecting to OpenShift...", - true, - null, - component - ) + }, + "Establishing secure connection...", + true, + null, + component + ) + } catch (e: ProcessCanceledException) { + return false + } catch (e: Exception) { + return handleConnectionFailure(server, e) + } + + val resolvedTlsContext = tlsContext + ?: return handleConnectionFailure(server, IllegalStateException("TLS context was not established")) + + try { + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking { + val indicator = ProgressManager.getInstance().progressIndicator + + try { + indicator.text = "Connecting to cluster..." + strategy.authenticate( + selectedCluster, + server, + resolvedTlsContext, + devSpacesContext, + indicator + ) + authResult = Result.success(Unit) + } catch (e: ProcessCanceledException) { + throw e + } catch (e: Exception) { + authResult = Result.failure(e) + } + } + }, + "Connecting to OpenShift...", + true, + null, + component + ) + } catch (e: ProcessCanceledException) { + return false + } - val result = authResult!! + val result = authResult + ?: return handleConnectionFailure(server, IllegalStateException("Authentication did not complete")) return result.fold( onSuccess = { settings.save(selectedCluster) true }, - onFailure = { e -> - thisLogger().warn(e) - if (!e.isLoginUserCancelled()) { - Dialogs.error( - "Could not connect to cluster ${server.stripScheme()}.\n\nReason: ${e.message ?: "Unknown error"}", - "Connection Failed" - ) - } - false - } + onFailure = { e -> handleConnectionFailure(server, e) } ) } + private fun handleConnectionFailure(server: String, e: Throwable): Boolean { + thisLogger().warn("Connection to $server failed", e) + if (!e.isLoginUserCancelled()) { + val reason = e.message ?: "Unknown error" + val tlsHint = if (e.isTlsRelated()) { + "\n\nTLS details were written to idea.log (search for \"TLS trust\")." + } else { + "" + } + Dialogs.error( + "Could not connect to cluster ${server.stripScheme()}.\n\nReason: $reason$tlsHint", + "Connection Failed" + ) + } + return false + } + private fun confirmAuthSwitchIfNeeded(): Boolean { val tokenPresent = findStrategy()?.tfToken?.password?.isNotEmpty() == true val certStrategy = findStrategy() @@ -504,19 +553,54 @@ class DevSpacesServerStepView( persistentKeyStore = persistentKeyStore ) - private suspend fun resolveSslContext(serverUrl: String): TlsContext { + private suspend fun resolveTlsContext( + serverUrl: String, + authMethod: AuthMethod, + certificateAuthority: CertificateSource?, + ): TlsContext { + val decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision = { info -> + UITlsDecisionAdapter.decide(info, component) + } + return when (authMethod) { + AuthMethod.OPENSHIFT, + AuthMethod.OPENSHIFT_CREDENTIALS -> + tlsTrustManager.ensureOpenShiftTlsContext( + serverUrl, + decisionHandler, + certificateAuthority, + ) + else -> + resolveSslContext(serverUrl, certificateAuthority) + } + } + + private suspend fun resolveSslContext( + serverUrl: String, + certificateAuthority: CertificateSource?, + ): TlsContext { + val decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision = { info -> + UITlsDecisionAdapter.decide(info, component) + } return tlsTrustManager.ensureTrusted( - serverUrl = serverUrl, - decisionHandler = UiTlsDecisionAdapter::decide + serverUrl, + decisionHandler, + certificateAuthority, + TlsEndpointKind.UNKNOWN, ) } + private fun resolveCertificateAuthority(input: String): CertificateSource? { + val source = CertificateSource.fromPathOrPem(input) ?: return null + source.validate() + return source + } + private suspend fun saveKubeconfig(cluster: Cluster, token: String, indicator: ProgressIndicator) { if (!saveConfig || token.isBlank()) return try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { KubeConfigUpdate .create( cluster.name.trim(), @@ -527,7 +611,7 @@ class DevSpacesServerStepView( } catch (e: Exception) { thisLogger().warn(e.message ?: "Could not save configuration file", e) - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { Dialogs.error(e.message ?: "Could not save configuration file", "Save Config Failed") } } @@ -541,7 +625,7 @@ class DevSpacesServerStepView( try { indicator.text = "Updating Kube config..." - withContext(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { KubeConfigUpdate .create( cluster.name.trim(), @@ -552,7 +636,7 @@ class DevSpacesServerStepView( } } catch (e: Exception) { thisLogger().warn(e.message ?: "Could not save configuration file", e) - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { Dialogs.error(e.message ?: "Could not save configuration file", "Save Config Failed") } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt index 48b7fa65..6d95a20f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -14,9 +14,8 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.openapi.progress.ProgressIndicator import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.auth.tls.TlsContext -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.Cluster -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import com.redhat.devtools.gateway.openshift.TlsClientBuilder import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient @@ -25,8 +24,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds /** @@ -65,40 +67,71 @@ abstract class AbstractAuthenticationStrategy( } /** - * Creates a validated API client. + * Builds a token-authenticated API client using the wizard TLS context. + * Runs synchronously so it is safe inside [ProgressManager.runProcessWithProgressSynchronously]. + */ + protected fun createTokenApiClient( + server: String, + token: String, + tlsContext: TlsContext, + ): ApiClient = + TlsClientBuilder( + server = server, + token = token, + clientCert = null, + clientKey = null, + tlsContext = tlsContext, + ).build() + + /** + * Creates a validated API client on a worker thread. + * Cluster TLS trust comes from [tlsContext] (established earlier in the wizard), not from + * kubeconfig certificate-authority paths that may be stale on this machine. */ @Throws(AuthenticationException::class) - protected fun createValidatedApiClient( + protected suspend fun createValidatedApiClient( server: String, - certAuthority: String? = null, token: String? = null, clientCert: String? = null, clientKey: String? = null, tlsContext: TlsContext, + probeApiAccess: Boolean = true, errorMessage: String? = null - ): ApiClient = try { - val caSource = CertificateSource.fromPathOrPem(certAuthority) - caSource?.validate() - val certSource = CertificateSource.fromPathOrPem(clientCert) - certSource?.validate() - val keySource = CertificateSource.fromPathOrPem(clientKey) - keySource?.validate() + ): ApiClient = withContext(Dispatchers.IO) { + coroutineContext.ensureActive() + try { + val certSource = resolveRequiredCertificateSource(clientCert) + val keySource = resolveRequiredCertificateSource(clientKey) - OpenShiftClientFactory(KubeConfigUtils) - .create( - server, - caSource, - token?.toCharArray(), - certSource, - keySource, - tlsContext - ) - .also { client -> - require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } - } - } catch (e: ApiException) { - throw AuthenticationException(e.codeToReasonPhrase(), e) - } catch (e: Exception) { - throw AuthenticationException(e.message ?: "Authentication failed", e) + TlsClientBuilder( + server = server, + token = token, + clientCert = certSource, + clientKey = keySource, + tlsContext = tlsContext + ).build() + .also { client -> + if (probeApiAccess) { + coroutineContext.ensureActive() + val authenticated = runInterruptible { + Projects(client).isAuthenticated() + } + require(authenticated) { errorMessage ?: "Not authenticated" } + } + } + } catch (e: ApiException) { + throw AuthenticationException(e.codeToReasonPhrase(), e) + } catch (e: Exception) { + throw AuthenticationException(e.message ?: "Authentication failed", e) + } + } + + /** + * Resolves client certificate/key input. Missing files fail authentication. + */ + private fun resolveRequiredCertificateSource(input: String?): CertificateSource? { + val source = CertificateSource.fromPathOrPem(input) ?: return null + source.validate() + return source } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt index 849f6203..0834d3f4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt @@ -44,16 +44,13 @@ interface AuthenticationStrategy { * * @param selectedCluster The cluster to authenticate against * @param server The server URL - * @param certAuthority The certificate authority data * @param tlsContext The TLS context for secure connections - * @param indicator The progress indicator * @param devSpacesContext The DevSpaces context to update - * @return true if authentication succeeded, false otherwise + * @param indicator The progress indicator */ suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt index 450589d2..c7718d5c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -76,7 +76,6 @@ class ClientCertificateAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -88,12 +87,10 @@ class ClientCertificateAuthenticationStrategy( val client = createValidatedApiClient( server, - certAuthority, - null, - clientCert, - clientKey, - tlsContext, - "Authentication failed: invalid client certificate or key." + clientCert = clientCert, + clientKey = clientKey, + tlsContext = tlsContext, + errorMessage = "Authentication failed: invalid client certificate or key." ) saveKubeconfigWithCert(selectedCluster, clientCert, clientKey, indicator) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt index 0228c517..664ea8bd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -40,7 +40,7 @@ class OpenShiftCredentialsAuthenticationStrategy( saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener, - private val setTokenDisplay: suspend (String) -> Unit + private val setTokenDisplay: (String) -> Unit, ) : AbstractAuthenticationStrategy( tfServer, saveKubeconfig @@ -104,7 +104,6 @@ class OpenShiftCredentialsAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -120,7 +119,7 @@ class OpenShiftCredentialsAuthenticationStrategy( apiServerUrl = selectedCluster.url, username = username, password = password, - tlsContext.sslContext + tlsContext.sslContext, ) val finalToken = TokenModel( @@ -131,17 +130,8 @@ class OpenShiftCredentialsAuthenticationStrategy( clusterApiUrl = selectedCluster.url ) - indicator.text = "Validating cluster access..." - - val client = createValidatedApiClient( - server, - certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: invalid OpenShift credentials." - ) + indicator.text = "Finishing connection..." + val client = createTokenApiClient(server, finalToken.accessToken, tlsContext) setTokenDisplay(finalToken.accessToken) saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt index eeab88a4..625b23b8 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.dsl.builder.panel import com.redhat.devtools.gateway.DevSpacesBundle @@ -31,7 +32,7 @@ import javax.swing.JPanel class OpenShiftOAuthAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, - private val setTokenDisplay: suspend (String) -> Unit + private val setTokenDisplay: (String) -> Unit, ) : AbstractAuthenticationStrategy( tfServer, saveKubeconfig @@ -51,53 +52,45 @@ class OpenShiftOAuthAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator ) { - indicator.text = "Authenticating with OpenShift..." - val sessionManager = OpenShiftAuthSessionManager() val login = sessionManager.startBrowserLogin( selectedCluster.url, - tlsContext.sslContext + tlsContext.sslContext, ) - withContext(Dispatchers.Main) { + + ApplicationManager.getApplication().invokeLater { BrowserUtil.browse(login.authorizationUri) } indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() - coroutineScope { - launchCancelWatcher(indicator) { login.cancel() } - - indicator.text = "Obtaining OpenShift access..." - val osToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - - val finalToken = TokenModel( - accessToken = osToken.accessToken, - expiresAt = osToken.expiresAt, - accountLabel = osToken.accountLabel, - kind = AuthTokenKind.TOKEN, - clusterApiUrl = selectedCluster.url - ) + supervisorScope { + val cancelJob = launchCancelWatcher(indicator) { login.cancel() } + try { + indicator.text = "Obtaining OpenShift access..." + val osToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - indicator.text = "Validating cluster access..." - val client = createValidatedApiClient( - server, - certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: token received from OpenShift Authenticator is invalid or expired." - ) + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) - setTokenDisplay(finalToken.accessToken) - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) - devSpacesContext.client = client + indicator.text = "Finishing connection..." + val client = createTokenApiClient(server, finalToken.accessToken, tlsContext) + devSpacesContext.client = client + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } finally { + cancelJob.cancel() + } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt index 94a91dbf..db8c2582 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -51,7 +51,6 @@ class RedHatSSOAuthenticationStrategy( override suspend fun authenticate( selectedCluster: Cluster, server: String, - certAuthority: String?, tlsContext: TlsContext, devSpacesContext: DevSpacesContext, indicator: ProgressIndicator @@ -79,12 +78,10 @@ class RedHatSSOAuthenticationStrategy( try { val client = createValidatedApiClient( - server, certAuthority, + server, finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + tlsContext = tlsContext, + errorMessage = "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." ) // Do not save SSO tokens diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt index 68fc6980..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..0f21ba06 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerCaTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.nio.file.Files +import javax.net.ssl.X509TrustManager + +class DefaultTlsTrustManagerCaTest { + + private val serverUrl = "https://api.example.com:6443" + + private fun createManager( + sessionTrustStore: SessionTlsTrustStore = SessionTlsTrustStore(), + ): DefaultTlsTrustManager { + val persistentPath = Files.createTempDirectory("tls-trust").resolve("truststore.p12") + return DefaultTlsTrustManager( + kubeConfigProvider = { emptyList() }, + kubeConfigWriter = { _, _ -> }, + sessionTrustStore = sessionTrustStore, + persistentKeyStore = PersistentKeyStore(persistentPath), + ) + } + + @Test + fun `#mergedContextFor includes wizard certificate authority`() { + runBlocking { + val expectedCert = TlsTestCertificates.caCertificate() + val manager = createManager() + + val tlsContext = manager.mergedContextFor( + listOf(serverUrl), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).contains(expectedCert.serialNumber) + } + } + + @Test + fun `#mergedContextFor uses session trust when certificates already accepted`() { + runBlocking { + val sessionCert = TlsTestCertificates.caCertificate() + val sessionStore = SessionTlsTrustStore().apply { + put(serverUrl, listOf(sessionCert)) + } + val manager = createManager(sessionTrustStore = sessionStore) + + val tlsContext = manager.mergedContextFor( + listOf(serverUrl), + TlsTestCertificates.caSourceFromData(), + ) + + val trustedSerials = (tlsContext.trustManager as X509TrustManager) + .acceptedIssuers + .map { it.serialNumber } + + assertThat(trustedSerials).containsExactly(sessionCert.serialNumber) + } + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt new file mode 100644 index 00000000..389d698f --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtilsTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import com.redhat.devtools.gateway.kubeconfig.KubeConfigCluster +import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.nio.file.Files + +class KubeConfigTlsUtilsTest { + + @Test + fun `#extractCaCertificates parses certificate-authority-data`() { + val source = TlsTestCertificates.caSourceFromData() + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().serialNumber) + .isEqualTo(TlsTestCertificates.caCertificate().serialNumber) + } + + @Test + fun `#extractCaCertificates reads certificate-authority file path`() { + val tempFile = Files.createTempFile("test-ca", ".pem") + tempFile.toFile().writeText(TlsTestCertificates.CA_PEM) + val source = CertificateSource.fromPath(tempFile.toString()) + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().subjectX500Principal.name) + .contains("CN=fake-unit-test.example.invalid") + } + + @Test + fun `#extractCaCertificates returns empty list for invalid data`() { + val source = CertificateSource.fromData("not-a-valid-cert") // notsecret + + val certificates = KubeConfigTlsUtils.extractCaCertificates(source) + + assertThat(certificates).isEmpty() + } + + @Test + fun `#extractCaCertificates delegates from named cluster`() { + val namedCluster = KubeConfigNamedCluster( + name = "test", + cluster = KubeConfigCluster( + server = "https://api.example.com:6443", + certificateAuthority = TlsTestCertificates.caSourceFromData(), + ), + ) + + val certificates = KubeConfigTlsUtils.extractCaCertificates(namedCluster) + + assertThat(certificates).hasSize(1) + assertThat(certificates.first().serialNumber) + .isEqualTo(TlsTestCertificates.caCertificate().serialNumber) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt new file mode 100644 index 00000000..557a43a4 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTestCertificates.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import java.security.cert.X509Certificate + +object TlsTestCertificates { + + // notsecret — synthetic self-signed fixture (see PemUtilsTest) + val CA_PEM: String = """ + -----BEGIN CERTIFICATE----- + MIIDlTCCAn2gAwIBAgIUJ/MyNwdZC5vGYJMyYa5m4letZrYwDQYJKoZIhvcNAQEL + BQAwWjEnMCUGA1UEAwweZmFrZS11bml0LXRlc3QuZXhhbXBsZS5pbnZhbGlkMSIw + IAYDVQQKDBlFeGFtcGxlIFRlc3QgRml4dHVyZSBPbmx5MQswCQYDVQQGEwJYWDAe + Fw0yNjA1MTMxMzE4MDJaFw0zNjA1MTAxMzE4MDJaMFoxJzAlBgNVBAMMHmZha2Ut + dW5pdC10ZXN0LmV4YW1wbGUuaW52YWxpZDEiMCAGA1UECgwZRXhhbXBsZSBUZXN0 + IEZpeHR1cmUgT25seTELMAkGA1UEBhMCWFgwggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQCG4CRbIkDOtpWzjWVW3V62FKzSfdAhdOJ/avqaPU2FiSjwEcBu + VceoT5ilVjNWuDSqWeTrmwPjBfzywpB9OHrziqE5rRBnlyuxTMgxxbpNU8WEBFtn + 2RWvKen0uZOOLTro1oQsI6ALqKd07s8t9XjIZMEiOzhvKzYK6xQiqXjnYJqWAw3Z + jhuvPcuvAALTXJMB6dASZNJ+q7gUd0gIMIjXVzAcj/QPxISwr3JMbpk+GvDnz0kF + t7TFQRMqW56dbK36ukjDvLdFd+bbigE6m55vsGVdyZC55wBIB87ycn0zc3hgrfej + 4JVEqEhhlsifUkjGqNR2h9cdY3u58gzJwZP5AgMBAAGjUzBRMB0GA1UdDgQWBBSn + 488Oxr0rTEaI1Q3xHhxERrAZ5jAfBgNVHSMEGDAWgBSn488Oxr0rTEaI1Q3xHhxE + RrAZ5jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAu0fWReMMg + SMM2ctyslZ/b00FDUnDq713HQ+HH3sB28NVxvwKUHR637Z/VzX2HNlR5wuR2ulxK + i6m54EBVCuE+T4kwPD/wx32RtGMAyuBlpamLC6WOdmVIVdYr66BRE7KdfTNnK+MJ + Aa0duD5KniqxkdMU7ZxveHM6RRv/hDg0qybOxLSwetmfI9CRiw0qOGiX5PhCqsJV + If1FxRl2mPPO0HiI94AyenmZfatuz9Y8Pb/q7cgdXpX2x29dnqXXO91qbVHk+zII + sYowqsdnMTfqNHFSJGrNovvI63/GQ/8148oKAALaH4VgNOyVIdaKkPDR5I/WBnNm + gJHFa/ozYnVi + -----END CERTIFICATE----- + """.trimIndent() + + fun caCertificate(): X509Certificate = PemUtils.parseCertificate(CA_PEM) + + fun caSourceFromData(): CertificateSource = + CertificateSource.fromData(PemUtils.toBase64(CA_PEM)) +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt new file mode 100644 index 00000000..237e281d --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientBuilderTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package com.redhat.devtools.gateway.openshift + +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.SslContextFactory +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import io.kubernetes.client.util.KubeConfig +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +class OpenShiftClientBuilderTest { + + private val tlsContext = SslContextFactory.insecure() + + /** Self-signed RSA fixture for this suite only (*.invalid); not from any cluster or public CA. */ + // notsecret + private val testClientCertPem = """ + -----BEGIN CERTIFICATE----- + MIIDlTCCAn2gAwIBAgIUJ/MyNwdZC5vGYJMyYa5m4letZrYwDQYJKoZIhvcNAQEL + BQAwWjEnMCUGA1UEAwweZmFrZS11bml0LXRlc3QuZXhhbXBsZS5pbnZhbGlkMSIw + IAYDVQQKDBlFeGFtcGxlIFRlc3QgRml4dHVyZSBPbmx5MQswCQYDVQQGEwJYWDAe + Fw0yNjA1MTMxMzE4MDJaFw0zNjA1MTAxMzE4MDJaMFoxJzAlBgNVBAMMHmZha2Ut + dW5pdC10ZXN0LmV4YW1wbGUuaW52YWxpZDEiMCAGA1UECgwZRXhhbXBsZSBUZXN0 + IEZpeHR1cmUgT25seTELMAkGA1UEBhMCWFgwggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQCG4CRbIkDOtpWzjWVW3V62FKzSfdAhdOJ/avqaPU2FiSjwEcBu + VceoT5ilVjNWuDSqWeTrmwPjBfzywpB9OHrziqE5rRBnlyuxTMgxxbpNU8WEBFtn + 2RWvKen0uZOOLTro1oQsI6ALqKd07s8t9XjIZMEiOzhvKzYK6xQiqXjnYJqWAw3Z + jhuvPcuvAALTXJMB6dASZNJ+q7gUd0gIMIjXVzAcj/QPxISwr3JMbpk+GvDnz0kF + t7TFQRMqW56dbK36ukjDvLdFd+bbigE6m55vsGVdyZC55wBIB87ycn0zc3hgrfej + 4JVEqEhhlsifUkjGqNR2h9cdY3u58gzJwZP5AgMBAAGjUzBRMB0GA1UdDgQWBBSn + 488Oxr0rTEaI1Q3xHhxERrAZ5jAfBgNVHSMEGDAWgBSn488Oxr0rTEaI1Q3xHhxE + RrAZ5jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAu0fWReMMg + SMM2ctyslZ/b00FDUnDq713HQ+HH3sB28NVxvwKUHR637Z/VzX2HNlR5wuR2ulxK + i6m54EBVCuE+T4kwPD/wx32RtGMAyuBlpamLC6WOdmVIVdYr66BRE7KdfTNnK+MJ + Aa0duD5KniqxkdMU7ZxveHM6RRv/hDg0qybOxLSwetmfI9CRiw0qOGiX5PhCqsJV + If1FxRl2mPPO0HiI94AyenmZfatuz9Y8Pb/q7cgdXpX2x29dnqXXO91qbVHk+zII + sYowqsdnMTfqNHFSJGrNovvI63/GQ/8148oKAALaH4VgNOyVIdaKkPDR5I/WBnNm + gJHFa/ozYnVi + -----END CERTIFICATE----- + """.trimIndent() + + // notsecret — PKCS#8 RSA key generated only for this test suite; not paired with a live cluster + private val testClientKeyPem = + "-----BEGIN PRIVATE KEY-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcxRFWa06rNo5xsdpGTLsETLviFAR4wdB0bylr6lHCuNpdW1gM1TyRvVvFyQWbJK+Dk+emSV7ocbUibUaxdhWQ1W1Jv7L/s3H4zzYdWpOF4LZ+W0wHVhav4AZjiU7GbvO15uK2gbfuEZHJ06uLTpKMh5uWRGpEBr0eNDE1Y6au1lpZtJSfgXuJRXHd+kbngjtmjb4XcW/3xCBbcAcpmXSCGgE9uV5uuYVmCwYBrLtHK5MUKz0i1F85XN2DEQwAEHEkg5d9Z0ypxoKHMRGmBkoN2t9SihAU04efHHKWk2GTDFZGY4Ga4w/YmmhoPM54gU7ONRN3LYRfThr0Y5ivJfPTAgMBAAECggEACqQ97GCAXeg9fG5BjirAjybToiG7DqS+t7NMBoKeENpncurea+Xq+fb2odMRdFnl0sgEHio2LQ/QPIlaa8rDj/9M2d1kvdgP0SnlAdJs19ZMd6tO2o33dZzUEjGh4p++ygbli39RXZxdCcGP5Xbsmml3dZh99ibW85PrZd+2fYD9hsO0CTRdlCLB0/Gy/yKW4iujlJyp1HfPFeiw/lKL5GwNSFpMJElwGcQUPVdXPqU+GzPJH1m54jFlYzIZCXuHW4U99+NPFj6foA5PjMS3ZXcEyWZfhuHbDYrqj3aKxRURWaNdGVxML6xSmdXvN/4yo7CkLUr1PN0apKACVS0FYQKBgQDUTP0aTp+ja3TNVtrv4K1v8Me3stlbxR9zeB/zL4QBjSkALBhz8xeYGYm4i1elH3Ch9jn2rHy9E8C7zxwZbW2mYHtV/Micyc6X03yqBuEVzsqlIxSUoUKM4yVTlje5jj6ggo/OJP86wUExbvsxkjocjrRitqk5eAlt6KHr5SxbqQKBgQC9CewRCFBJQpYaHDEpyVHlsgM6qwP4W4VvStScTQ1hXnHE7g0mQhKxiS+WgF6RJkhiJhTvRfSVm0/3PSLa9woEtgiNx+cPscHLFvR0y4RCbjA1QDIGLbQV9/e6ntnlup4nFrCEgA17oQtb/EGXMAIRL2SdsGpd3YEWrSchOxuhGwKBgQDJeyt17Qo6OMAIJJbxswRGyXdxUm5QVtsLZgTEceLQ6hvwSukGGb3ZntsCZlPOpPDq9Nh7z6UueHGgi+U6CI1YqhZDO/1UN342vwKABrlVTgUqBgoBKK4VMXl6Q4UtN98dy+sYlCoZo9DwTkhc+k7mTVTKnlop7U7dnTsWuk+HyQKBgHOAm39wr/WDPMlpTlS00FhjIvv2v+9ApE/yzeNOZQ2IMkVcGia1GkzlgHEZsC5J0NI/aG0mNiIvCnYLIb/eT32/Z4yRhsmdF8aqGOU/8GjSgJwYxDfoNu9xWijppENsefNyNppOz24pYRJsF/tzdt/fMD/1KZh+ncAoPg9c2S3fAoGAWzYz9FFDIXv8yx8e5eGJstq+F2GkOrTliPfjX5PP1NkIJ8vFxGVE6RKzn8FSoE+Xxz5GjcULoE0hno7p2oYqLQpd7pI3LyLTSZhTN0FKDHQQpPtzoo6hSda53i3AaI0VO6mRi3VJaSoWhUkz/4ULR1NuuWpW2oFD2hIEQZqkiDI=-----END PRIVATE KEY-----" + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `TlsClientBuilder sets basePath for token auth`() { + val client = TlsClientBuilder( + server = "https://api.example.com:6443/", + token = "test-token", // notsecret + tlsContext = tlsContext, + ).build() + + assertThat(client.basePath).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `TlsClientBuilder sets basePath for client certificate auth`() { + val client = TlsClientBuilder( + server = "https://api.example.com:6443/", + clientCert = CertificateSource.fromData(testClientCertPem), + clientKey = CertificateSource.fromData(testClientKeyPem), + tlsContext = tlsContext, + ).build() + + assertThat(client.basePath).isEqualTo("https://api.example.com:6443") + } + + @Test + fun `TlsClientBuilder rejects missing auth`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TlsClientBuilder rejects both token and client certificate`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + token = "test-token", // notsecret + clientCert = CertificateSource.fromData(testClientCertPem), + clientKey = CertificateSource.fromData(testClientKeyPem), + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TlsClientBuilder rejects client certificate without key`() { + assertThatThrownBy { + TlsClientBuilder( + server = "https://api.example.com:6443", + clientCert = CertificateSource.fromData(testClientCertPem), + tlsContext = tlsContext, + ).build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `TokenClientBuilder applies read timeout`() { + val client = TokenClientBuilder("https://api.example.com:6443", "test-token") // notsecret + .readTimeout(45, TimeUnit.SECONDS) + .build() + + assertThat(client.httpClient.readTimeoutMillis).isEqualTo(45_000) + } + + @Test + fun `TokenClientBuilder rejects empty token`() { + assertThatThrownBy { + TokenClientBuilder("https://api.example.com:6443", "") + .build() + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Provide either token OR clientCert + clientKey") + } + + @Test + fun `DefaultClientBuilder falls back when no kubeconfig files exist`() { + val configUtils = mockk() + every { configUtils.getAllConfigFiles() } returns emptyList() + + runCatching { DefaultClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 0) { configUtils.getAllConfigs(any()) } + } + + @Test + fun `DefaultClientBuilder falls back when kubeconfig merge fails`() { + val configUtils = mockk() + val configPath = mockk() + every { configUtils.getAllConfigFiles() } returns listOf(configPath) + every { configUtils.getAllConfigs(listOf(configPath)) } throws RuntimeException("invalid yaml") + + runCatching { DefaultClientBuilder(configUtils).build() } + + verify(exactly = 1) { configUtils.getAllConfigFiles() } + verify(exactly = 1) { configUtils.getAllConfigs(listOf(configPath)) } + verify(exactly = 0) { configUtils.mergeConfigs(any()) } + } + + @Test + fun `DefaultClientBuilder builds from merged kubeconfig`() { + val configUtils = mockk() + val configPath = mockk() + val kubeConfig = KubeConfig( + arrayListOf( + mapOf( + "name" to "test-context", + "context" to mapOf( + "cluster" to "test-cluster", + "user" to "test-user", + ), + ), + ), + arrayListOf( + mapOf( + "name" to "test-cluster", + "cluster" to mapOf("server" to "https://merged.example.com:6443"), + ), + ), + arrayListOf( + mapOf( + "name" to "test-user", + "user" to mapOf("token" to "merged-token"), // notsecret + ), + ), + ) + kubeConfig.setContext("test-context") + + every { configUtils.getAllConfigFiles() } returns listOf(configPath) + every { configUtils.getAllConfigs(listOf(configPath)) } returns listOf(kubeConfig) + every { configUtils.mergeConfigs(listOf(kubeConfig)) } returns kubeConfig + + val client = DefaultClientBuilder(configUtils).build() + + assertThat(client.basePath).isEqualTo("https://merged.example.com:6443") + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt new file mode 100644 index 00000000..ba9fffd0 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/util/ExceptionUtilsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import javax.net.ssl.SSLHandshakeException + +class ExceptionUtilsTest { + + @Test + fun `#isTlsRelated detects PKIX errors`() { + val error = SSLHandshakeException( + "PKIX path building failed: unable to find valid certification path to requested target" + ) + + assertThat(error.isTlsRelated()).isTrue() + } + + @Test + fun `#isTlsRelated ignores unrelated errors`() { + assertThat(IllegalStateException("not authenticated").isTlsRelated()).isFalse() + } +} diff --git a/terminal-to-che.sh b/terminal-to-che.sh new file mode 100755 index 00000000..906c199e --- /dev/null +++ b/terminal-to-che.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +pattern="${1:-workspace}" + +kubectl exec -it $(kubectl get pod | grep -o "${pattern}\S\+" | head -n 1) -- bash