From e3e1b37ef016db256837c708c2b4154f1d84d790 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Tue, 20 Jan 2026 19:20:40 +0100 Subject: [PATCH 01/32] feat: Gateway: CRW-8927 - Simplify login in to the OCP cluster from the Gateway plugin This PR adds a possibility to authorize on... - ...an OpenShift cluster via OpenShift OAuth Authenticator - ...a Sandbox provisioned cluster via RedHat SSO Authenticator Signed-off-by: Victor Rubezhny Assisted-by: OpenAI ChatGPT --- build.gradle.kts | 12 ++ .../gateway/auth/code/AuthCodeFlow.kt | 68 ++++++ .../auth/code/IdeaSecureTokenStorage.kt | 57 +++++ .../auth/code/JBPasswordSafeTokenStorage.kt | 51 +++++ .../auth/code/OpenShiftAuthCodeFlow.kt | 152 ++++++++++++++ .../gateway/auth/code/RedHatAuthCodeFlow.kt | 143 +++++++++++++ .../gateway/auth/code/SecureTokenStorage.kt | 18 ++ .../gateway/auth/config/AuthConfig.kt | 35 ++++ .../devtools/gateway/auth/config/AuthType.kt | 17 ++ .../gateway/auth/config/ServerConfig.kt | 33 +++ .../auth/oidc/OidcProviderMetadataResolver.kt | 36 ++++ .../gateway/auth/sandbox/SandboxApi.kt | 67 ++++++ .../sandbox/SandboxClusterAuthProvider.kt | 105 ++++++++++ .../gateway/auth/sandbox/SandboxDefaults.kt | 28 +++ .../gateway/auth/sandbox/SandboxModels.kt | 49 +++++ .../gateway/auth/server/CallbackServer.kt | 26 +++ .../gateway/auth/server/LocalServerConfig.kt | 23 ++ .../auth/server/OAuthCallbackServer.kt | 75 +++++++ .../gateway/auth/server/RedirectUrlBuilder.kt | 44 ++++ .../auth/server/ServerConfigProvider.kt | 30 +++ .../auth/server/che/CheServerConfig.kt | 39 ++++ .../auth/session/AuthSessionListener.kt | 16 ++ .../auth/session/AuthSessionManager.kt | 36 ++++ .../session/OpenShiftAuthSessionManager.kt | 168 +++++++++++++++ .../auth/session/RedHatAuthSessionManager.kt | 197 ++++++++++++++++++ .../gateway/auth/session/SsoLoginException.kt | 19 ++ .../devtools/gateway/openshift/Projects.kt | 29 +++ .../view/steps/DevSpacesServerStepView.kt | 184 ++++++++++++++-- 28 files changed, 1742 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/LocalServerConfig.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/RedirectUrlBuilder.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/ServerConfigProvider.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/server/che/CheServerConfig.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6894540d..2e2ecf5c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + kotlin("plugin.serialization") version "1.9.22" // Serialization needed for RedHat Auth } group = providers.gradleProperty("pluginGroup").get() @@ -71,6 +72,17 @@ dependencies { implementation("io.kubernetes:client-java:26.0.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.21.3") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.3") + + // RedHat Auth dependencies + implementation("io.ktor:ktor-server-core-jvm:2.3.7") + implementation("io.ktor:ktor-server-netty-jvm:2.3.7") + implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.7") + + implementation("com.nimbusds:oauth2-oidc-sdk:11.15") // Core OIDC/OAuth2 + implementation("com.nimbusds:nimbus-jose-jwt:9.37") // JWT processing + + // JSON serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } // Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt new file mode 100644 index 00000000..a83b8e53 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt @@ -0,0 +1,68 @@ +/* + * 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.code + +import java.net.URI +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier +import com.nimbusds.openid.connect.sdk.Nonce +import com.redhat.devtools.gateway.auth.server.Parameters +import kotlinx.serialization.Serializable + +/** + * Represents the data needed to start the PKCE + Auth Code flow. + */ +data class AuthCodeRequest( + val authorizationUri: URI, // URL to open in browser + val codeVerifier: CodeVerifier, // Used for token exchange + val nonce: Nonce // Anti-replay / OIDC nonce +) + +/** + * Represents the SSO Token + */ +data class SSOToken( + val accessToken: String, + val idToken: String, + val accountLabel: String, + val expiresAt: Long? = null +) { + fun isExpired(now: Long = System.currentTimeMillis()): Boolean = + expiresAt?.let { now >= it } ?: false +} + +/** + * Represents the final result after exchanging code for tokens. + */ +enum class AuthTokenKind { + SSO, + TOKEN, + PIPELINE +} + +@Serializable +data class TokenModel( + val accessToken: String, + val expiresAt: Long?, // null = non-expiring (pipeline) + val accountLabel: String, + val kind: AuthTokenKind, + val clusterApiUrl: String, + val namespace: String? = null, + val serviceAccount: String? = null +) + +interface AuthCodeFlow { + /** Starts the auth flow and returns the info to open the browser */ + suspend fun startAuthFlow(): AuthCodeRequest + + /** Handles the redirect/callback and returns the final tokens */ + suspend fun handleCallback(parameters: Parameters): SSOToken +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt new file mode 100644 index 00000000..2939029e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt @@ -0,0 +1,57 @@ +/* + * 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 + */ +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + +package com.redhat.devtools.gateway.auth.code + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class IdeaSecureTokenStorage : SecureTokenStorage { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private val attributes = CredentialAttributes( + "com.redhat.devtools.gateway.auth.sso" + ) + + override suspend fun saveToken(token: TokenModel) { + val serialized = json.encodeToString(token) + + PasswordSafe.instance.set( + attributes, + Credentials("sso", serialized) + ) + } + + override suspend fun loadToken(): TokenModel? { + val credentials = PasswordSafe.instance.get(attributes) + ?: return null + + val raw = credentials.password?.toString() + ?: return null + + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } + + override suspend fun clearToken() { + PasswordSafe.instance.set(attributes, null) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt new file mode 100644 index 00000000..5b42c8f0 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt @@ -0,0 +1,51 @@ +/* + * 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.code + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class JBPasswordSafeTokenStorage : SecureTokenStorage { + + private val attributes = CredentialAttributes( + generateServiceName( + "RedHatGatewayPlugin", + "RedHatAuthToken" + ) + ) + + override suspend fun saveToken(token: TokenModel) { + val json = Json.encodeToString(token) + + val credentials = Credentials( + "redhat", + json + ) + + PasswordSafe.instance.set(attributes, credentials) + } + + override suspend fun loadToken(): TokenModel? { + val credentials = PasswordSafe.instance.get(attributes) ?: return null + val json = credentials.getPasswordAsString() ?: return null + return Json.decodeFromString(json) + } + + override suspend fun clearToken() { + PasswordSafe.instance.set(attributes, null) + } +} + 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 new file mode 100644 index 00000000..971cfe0f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -0,0 +1,152 @@ +/* + * 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.code + +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import com.nimbusds.oauth2.sdk.ResponseType +import com.nimbusds.oauth2.sdk.id.ClientID +import com.nimbusds.oauth2.sdk.id.State +import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier +import com.nimbusds.openid.connect.sdk.Nonce +import com.redhat.devtools.gateway.auth.server.Parameters +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * Canonical OpenShift OAuth flow (PKCE + Authorization Code), mimics `oc login --web`. + * Does NOT require RH SSO token. + */ +class OpenShiftAuthCodeFlow( + private val apiServerUrl: String, // Cluster API server + private val redirectUri: URI // Local callback server URI +) : AuthCodeFlow { + + private lateinit var codeVerifier: CodeVerifier + private lateinit var state: State + + private lateinit var metadata: OAuthMetadata + + private val json = Json { ignoreUnknownKeys = true } + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .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 url = "$apiServerUrl/.well-known/oauth-authorization-server" + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + 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() + codeVerifier = CodeVerifier() + state = State() + + val request = AuthorizationRequest.Builder( + ResponseType.CODE, + ClientID("openshift-cli-client") // same as oc + ) + .endpointURI(URI(metadata.authorizationEndpoint)) + .redirectionURI(redirectUri) + .codeChallenge(codeVerifier, CodeChallengeMethod.S256) + .build() + + return AuthCodeRequest( + authorizationUri = request.toURI(), + codeVerifier = codeVerifier, + nonce = Nonce() + ) + } + + @Serializable + data class AccessTokenResponseJson( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long + ) + + override suspend fun handleCallback(parameters: Parameters): SSOToken { + val code: String = parameters["code"] ?: error("Missing 'code' parameter in callback") + + val basicAuth = "Basic " + Base64.getEncoder() + .encodeToString("openshift-cli-client:".toByteArray(StandardCharsets.UTF_8)) + + fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" + } + + val form = encodeForm( + "grant_type" to "authorization_code", + "client_id" to "openshift-cli-client", + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + 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.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + error("Token request failed: ${response.statusCode()}\n${response.body()}") + } + + val token = json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + + return SSOToken( + accessToken = token.accessToken, + idToken = "", // OpenShift does not issue id_token + accountLabel = "openshift-user", + expiresAt = expiresAt + ) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt new file mode 100644 index 00000000..a254aa53 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -0,0 +1,143 @@ +/* + * 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.code + +import com.nimbusds.jwt.JWTParser +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.oauth2.sdk.ResponseType +import com.nimbusds.oauth2.sdk.Scope +import com.nimbusds.oauth2.sdk.id.ClientID +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.AuthenticationRequest +import com.nimbusds.openid.connect.sdk.Nonce +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import com.redhat.devtools.gateway.auth.server.Parameters +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * RedHat SSO OAuth Flow. + * Creates and returns a Service Account pipeline token limited only for Sandboxed clusters + */ +class RedHatAuthCodeFlow( + private val clientId: String, + private val redirectUri: URI, + private val providerMetadata: OIDCProviderMetadata +) : AuthCodeFlow { + + private lateinit var codeVerifier: CodeVerifier + private lateinit var nonce: Nonce + private lateinit var state: State + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + override suspend fun startAuthFlow(): AuthCodeRequest { + codeVerifier = CodeVerifier() + nonce = Nonce() + state = State() + + val request = AuthenticationRequest.Builder( + ResponseType.CODE, + Scope("openid", "profile", "email"), + ClientID(clientId), + redirectUri + ) + .endpointURI(providerMetadata.authorizationEndpointURI) + .codeChallenge(codeVerifier, CodeChallengeMethod.S256) + .nonce(nonce) + .state(state) + .build() + + return AuthCodeRequest( + authorizationUri = request.toURI(), + codeVerifier = codeVerifier, + nonce = nonce + ) + } + + override suspend fun handleCallback(parameters: Parameters): SSOToken { + val code = parameters["code"] ?: error("Missing 'code' parameter in callback") + + fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" + } + + val form = encodeForm( + "grant_type" to "authorization_code", + "client_id" to clientId, + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + val basicAuth = "Basic " + Base64.getEncoder() + .encodeToString("$clientId:".toByteArray(StandardCharsets.UTF_8)) + + val request = HttpRequest.newBuilder() + .uri(providerMetadata.tokenEndpointURI) + .header("Authorization", basicAuth) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + error("Token request failed: ${response.statusCode()}\n${response.body()}") + } + + val json = Json { ignoreUnknownKeys = true } + val body = json.parseToJsonElement(response.body()).jsonObject + + val accessToken = body["access_token"]?.jsonPrimitive?.content + ?: error("Missing access_token in token response") + + val idToken = body["id_token"]?.jsonPrimitive?.content.orEmpty() + val expiresInSeconds = body["expires_in"]?.jsonPrimitive?.longOrNull ?: 3600 + val accountLabel = if (idToken.isNotBlank()) { + try { + val jwt = JWTParser.parse(idToken) as SignedJWT + val claims = jwt.jwtClaimsSet + + claims.getStringClaim("preferred_username") + ?: claims.getStringClaim("email") + ?: "unknown-user" + } catch (e: Exception) { + "unknown-user" + } + } else { + "unknown-user" + } + + return SSOToken( + accessToken = accessToken, + idToken = idToken, + expiresAt = System.currentTimeMillis() + expiresInSeconds * 1000, + accountLabel = accountLabel + ) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt new file mode 100644 index 00000000..97290403 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt @@ -0,0 +1,18 @@ +/* + * 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.code + +interface SecureTokenStorage { + suspend fun saveToken(token: TokenModel) + suspend fun loadToken(): TokenModel? + suspend fun clearToken() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt new file mode 100644 index 00000000..fa48d0c8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt @@ -0,0 +1,35 @@ +/* + * 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.config + +data class AuthConfig( + val serviceId: String = "redhat-account-auth", + + val authUrl: String = + System.getenv("REDHAT_SSO_URL") + ?: "https://sso.redhat.com/auth/realms/redhat-external/", + + val apiUrl: String = + System.getenv("KAS_API_URL") + ?: "https://api.openshift.com", + + val clientId: String = + System.getenv("CLIENT_ID") + ?: "vscode-redhat-account", + + val deviceCodeOnly: Boolean = + System.getenv("DEVICE_CODE_ONLY") + ?.equals("true", ignoreCase = true) + ?: false, + + val authType: AuthType = AuthType.SSO_REDHAT +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt new file mode 100644 index 00000000..da7a8249 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt @@ -0,0 +1,17 @@ +/* + * 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.config + +enum class AuthType(val value: String) { + SSO_REDHAT("sso-redhat"), + SSO_OPENSHIFT("sso-openshift") +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt new file mode 100644 index 00000000..08a97c35 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt @@ -0,0 +1,33 @@ +/* + * 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.config + +data class ServerConfig( + /** + * Path relative to externalUrl + * Example: sso-redhat-callback + */ + val callbackPath: String, + + /** + * Fully qualified external base URL + * Examples: + * - http://localhost + * - https://workspace-id.openshiftapps.com + */ + val externalUrl: String, + + /** + * Local listening port (optional, dynamic if null) + */ + val port: Int? = null +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt new file mode 100644 index 00000000..ccb710d4 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt @@ -0,0 +1,36 @@ +/* + * 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.oidc + +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata + +class OidcProviderMetadataResolver( + authUrl: String +) { + private val issuer = Issuer(authUrl) + + @Volatile + private var cached: OIDCProviderMetadata? = null + + suspend fun resolve(): OIDCProviderMetadata { + cached?.let { return it } + + val request = OIDCProviderConfigurationRequest(issuer) + val httpResponse = request.toHTTPRequest().send() + val metadata = OIDCProviderMetadata.parse(httpResponse.bodyAsJSONObject) + + cached = metadata + return metadata + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt new file mode 100644 index 00000000..2df90d1b --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt @@ -0,0 +1,67 @@ +/* + * 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.sandbox + +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +class SandboxApi( + private val baseUrl: String, + private val timeoutMs: Long +) { + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + private val json = Json { + ignoreUnknownKeys = true + } + + fun getSignUpStatus(ssoToken: String): SandboxSignupResponse? { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .GET() + .build() + + val response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ) + + if (response.statusCode() != 200) { + return null + } + + return json.decodeFromString(response.body()) + } + + fun signUp(ssoToken: String): Boolean { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .POST(HttpRequest.BodyPublishers.noBody()) + .build() + + val response = httpClient.send( + request, + HttpResponse.BodyHandlers.discarding() + ) + + return response.statusCode() in 200..299 + } +} 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 new file mode 100644 index 00000000..e84ffdf1 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt @@ -0,0 +1,105 @@ +/* + * 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.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 io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.V1ObjectMeta +import io.kubernetes.client.openapi.models.V1Secret +import io.kubernetes.client.openapi.models.V1ServiceAccount +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import java.util.* +import java.util.concurrent.TimeUnit + +class SandboxClusterAuthProvider( + private val sandboxApi: SandboxApi = SandboxApi( + SandboxDefaults.SANDBOX_API_BASE_URL, + SandboxDefaults.SANDBOX_API_TIMEOUT_MS + ) +) { + fun authenticate(ssoToken: SSOToken): TokenModel { + val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) + ?: error("Sandbox not available") + + if (!signup.status.ready) error("Sandbox not ready") + + val username = signup.compliantUsername ?: signup.username + val namespace = "$username-dev" + + val client: ApiClient = ClientBuilder.standard() + .setBasePath(signup.proxyUrl!!) + .setAuthentication(AccessTokenAuthentication(ssoToken.idToken)) + .build() + .also { it.httpClient = it.httpClient.newBuilder().readTimeout(30, TimeUnit.SECONDS).build() } + + val coreV1Api = CoreV1Api(client) + val pipelineSA = ensurePipelineServiceAccount(coreV1Api, namespace) + val pipelineSecret = ensurePipelineTokenSecret(coreV1Api, namespace, pipelineSA) + val pipelineToken = extractToken(pipelineSecret) + + return TokenModel( + accessToken = pipelineToken, + expiresAt = null, // non-expiring pipeline token + accountLabel = ssoToken.accountLabel, + kind = AuthTokenKind.PIPELINE, + clusterApiUrl = signup.apiEndpoint, + namespace = namespace, + serviceAccount = "pipeline" + ) + } + + private fun ensurePipelineServiceAccount(api: CoreV1Api, namespace: String): V1ServiceAccount { + val saList = api.listNamespacedServiceAccount(namespace).execute() + ?: error("Failed to list ServiceAccounts") + + return saList.items.firstOrNull { it.metadata?.name == "pipeline" } + ?: api.createNamespacedServiceAccount( + namespace, + V1ServiceAccount().metadata(V1ObjectMeta().name("pipeline")) + ).execute() ?: error("Failed to create pipeline ServiceAccount") + } + + private fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret { + val secretName = "pipeline-secret-${sa.metadata?.name}" + val secretList = api.listNamespacedSecret(namespace).execute() + ?: error("Failed to list Secrets") + + secretList.items.firstOrNull { it.metadata?.name == secretName && it.data?.containsKey("token") == true } + ?.let { return it } + + val secret = V1Secret().metadata( + V1ObjectMeta() + .name(secretName) + .putAnnotationsItem("kubernetes.io/service-account.name", sa.metadata!!.name) + .putAnnotationsItem("kubernetes.io/service-account.uid", sa.metadata!!.uid) + ).type("kubernetes.io/service-account-token") + + api.createNamespacedSecret(namespace, secret).execute() + + repeat(30) { + val s = api.readNamespacedSecret(secretName, namespace).execute() + if (s.data?.containsKey("token") == true) return s + Thread.sleep(1000) + } + + error("Pipeline token secret not populated") + } + + private fun extractToken(secret: V1Secret): String { + val tokenBytes = secret.data?.get("token") ?: error("Token missing in secret") + return String(tokenBytes, Charsets.UTF_8) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt new file mode 100644 index 00000000..80d691bc --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt @@ -0,0 +1,28 @@ +/* + * 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.sandbox + +object SandboxDefaults { + + /** + * Matches VS Code default: + * openshiftToolkit.sandboxApiHostUrl + */ + const val SANDBOX_API_BASE_URL = + "https://registration-service-toolchain-host-operator.apps.sandbox.x8i5.p1.openshiftapps.com" + + /** + * Matches VS Code default: + * openshiftToolkit.sandboxApiTimeout + */ + const val SANDBOX_API_TIMEOUT_MS: Long = 100_000 +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt new file mode 100644 index 00000000..d208338d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt @@ -0,0 +1,49 @@ +/* + * 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.sandbox + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SandboxSignupResponse( + @SerialName("apiEndpoint") + val apiEndpoint: String, + + @SerialName("proxyURL") + val proxyUrl: String? = null, + + @SerialName("clusterName") + val clusterName: String? = null, + + @SerialName("consoleURL") + val consoleUrl: String? = null, + + @SerialName("username") + val username: String, + + @SerialName("compliantUsername") + val compliantUsername: String? = null, + + @SerialName("status") + val status: SandboxStatus +) + +@Serializable +data class SandboxStatus( + val ready: Boolean, + + @SerialName("verificationRequired") + val verificationRequired: Boolean = false, + + val reason: String? = null +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt new file mode 100644 index 00000000..3485e510 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt @@ -0,0 +1,26 @@ +/* + * 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.server + +typealias Parameters = Map + +interface CallbackServer { + + /** Starts the server and registers a callback handler and returns the port it's bound to */ + suspend fun start(): Int + + /** Stops the server */ + suspend fun stop() + + /** Wait for server receives the Parameters or cancelled */ + suspend fun awaitCallback(timeoutMs: Long): Parameters? +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/LocalServerConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/LocalServerConfig.kt new file mode 100644 index 00000000..7c3aa3d9 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/LocalServerConfig.kt @@ -0,0 +1,23 @@ +/* + * 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.server + +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.config.ServerConfig + +internal fun getLocalServerConfig(type: AuthType): ServerConfig { + return ServerConfig( + callbackPath = if (type == AuthType.SSO_REDHAT) "${type.value}-callback" else "callback", + externalUrl = if (type == AuthType.SSO_REDHAT) "http://localhost" else "http://127.0.0.1", + port = null // dynamic + ) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt new file mode 100644 index 00000000..d43c7b3a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.server + +import com.redhat.devtools.gateway.auth.config.ServerConfig +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import java.net.InetSocketAddress +import java.net.URLDecoder +import java.util.concurrent.Executors +import com.sun.net.httpserver.HttpServer + +class OAuthCallbackServer( + private val serverConfig: ServerConfig +) : CallbackServer { + + private var server: HttpServer? = null + private var callbackDeferred: CompletableDeferred? = null + + override suspend fun start(): Int { + if (server != null) return server!!.address.port + + callbackDeferred = CompletableDeferred() + + server = HttpServer.create(InetSocketAddress("127.0.0.1", serverConfig.port ?: 0), 0) + server!!.executor = Executors.newSingleThreadExecutor() + + server!!.createContext("/") { exchange -> + val path = exchange.requestURI.path + if (path == "/${serverConfig.callbackPath}") { + val query = exchange.requestURI.query ?: "" + val params: Parameters = query.split("&") + .mapNotNull { + val pair = it.split("=", limit = 2) + if (pair.isNotEmpty()) pair[0] to pair.getOrElse(1) { "" } else null + } + .associate { it.first to URLDecoder.decode(it.second, "UTF-8") } + callbackDeferred?.complete(params) + + val response = "Authentication successful. You may close this window." + exchange.sendResponseHeaders(200, response.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(response.toByteArray()) } + } else if (path == "/signin") { + val response = "Sign-in initialized. You may continue." + exchange.sendResponseHeaders(200, response.toByteArray().size.toLong()) + exchange.responseBody.use { it.write(response.toByteArray()) } + } else { + exchange.sendResponseHeaders(404, 0) + exchange.responseBody.close() + } + } + + server!!.start() + return server!!.address.port + } + + override suspend fun stop() { + server?.stop(0) + server = null + callbackDeferred?.cancel() + callbackDeferred = null + } + + override suspend fun awaitCallback(timeoutMs: Long): Parameters? = + withTimeoutOrNull(timeoutMs) { callbackDeferred?.await() } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/RedirectUrlBuilder.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/RedirectUrlBuilder.kt new file mode 100644 index 00000000..0a068dc2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/RedirectUrlBuilder.kt @@ -0,0 +1,44 @@ +/* + * 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.server + +import com.redhat.devtools.gateway.auth.config.ServerConfig +import java.net.URI + +object RedirectUrlBuilder { + + fun signinUrl(serverConfig: ServerConfig, port: Int, nonce: String): URI { + val base = URI(serverConfig.externalUrl) + return URI( + base.scheme, + base.authority, + "/signin", + "nonce=$nonce", + null + ).let { + if (base.port == -1) + URI(it.scheme, it.userInfo, it.host, port, it.path, it.query, it.fragment) + else it + } + } + + fun callbackUrl(serverConfig: ServerConfig, port: Int): URI { + val base = URI(serverConfig.externalUrl) + val path = "/${serverConfig.callbackPath}" + + return URI(base.scheme, base.authority, path, null, null).let { + if (base.port == -1) + URI(it.scheme, it.userInfo, it.host, port, it.path, it.query, it.fragment) + else it + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/ServerConfigProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/ServerConfigProvider.kt new file mode 100644 index 00000000..a2b6fd52 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/ServerConfigProvider.kt @@ -0,0 +1,30 @@ +/* + * 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.server + +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.config.ServerConfig +import com.redhat.devtools.gateway.auth.server.che.getCheServerConfig + +object ServerConfigProvider { + + suspend fun getServerConfig(type: AuthType): ServerConfig { + return if (isCheEnvironment()) { + getCheServerConfig(type) + } else { + getLocalServerConfig(type) + } + } + + private fun isCheEnvironment(): Boolean = + System.getenv("CHE_WORKSPACE_ID") != null +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/che/CheServerConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/che/CheServerConfig.kt new file mode 100644 index 00000000..176e95d8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/che/CheServerConfig.kt @@ -0,0 +1,39 @@ +/* + * 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.server.che + +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.config.ServerConfig + +@Suppress("UNCHECKED_CAST") +internal suspend fun getCheServerConfig(type: AuthType): ServerConfig { + val endpointName = type.value + + val cheApi = try { + Class.forName("@eclipse-che.plugin") + } catch (_: Throwable) { + throw IllegalStateException("Che plugin API not available") + } + + // NOTE: + // JetBrains does not ship Che APIs by default. + // This code is intentionally reflective to avoid runtime crashes. + val che = cheApi.getDeclaredConstructor().newInstance() + + // TODO: + // In STEP 6 we will replace this with proper Che / Dev Spaces APIs + // once JetBrains Gateway mapping is finalized. + + throw IllegalStateException( + "Che server config resolution will be finalized in STEP 6" + ) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt new file mode 100644 index 00000000..a1a8d339 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt @@ -0,0 +1,16 @@ +/* + * 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.session + +interface AuthSessionListener { + fun sessionChanged() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt new file mode 100644 index 00000000..d8a47d02 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -0,0 +1,36 @@ +/* + * 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.session + +import com.redhat.devtools.gateway.auth.code.SSOToken +import java.net.URI + +interface AuthSessionManager { + + /** Called once on plugin startup to load any existing token. */ + suspend fun initialize() + + /** Starts login and returns browser URL */ + suspend fun startLogin(apiServerUrl: String? = null): URI + + /** Returns a valid (non-expired) token or null. Refreshes automatically if possible. */ + suspend fun getValidToken(): SSOToken? + + /** Clears session and stored tokens. */ + suspend fun logout() + + /** Returns true if a session is active. */ + fun isLoggedIn(): Boolean + + /** Returns the current account label, if logged in. */ + fun currentAccount(): String? +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt new file mode 100644 index 00000000..ad52443a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -0,0 +1,168 @@ +/* + * 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.session + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.components.Service +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.server.CallbackServer +import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer +import com.redhat.devtools.gateway.auth.server.Parameters +import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder +import com.redhat.devtools.gateway.auth.server.ServerConfigProvider +import kotlinx.coroutines.* +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean + +const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class OpenShiftAuthSessionManager : AuthSessionManager { + + private val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_OPENSHIFT) // or another type if you distinguish + } + + private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private lateinit var authFlow: OpenShiftAuthCodeFlow + + private val listeners = mutableSetOf() + private var currentToken: SSOToken? = null + private val loginInProgress = AtomicBoolean(false) + private var pendingLogin: CompletableDeferred? = null + + fun isLoginInProgress(): Boolean = loginInProgress.get() + + fun addListener(listener: AuthSessionListener) { + listeners += listener + } + + fun removeListener(listener: AuthSessionListener) { + listeners -= listener + } + + private fun notifyChanged() { + listeners.forEach { it.sessionChanged() } + } + + override suspend fun initialize() { + notifyChanged() + } + + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + return try { + withTimeout(timeoutMs) { deferred.await() } + } catch (_: TimeoutCancellationException) { + throw SsoLoginException.Timeout + } + } + + override suspend fun startLogin(apiServerUrl: String?): URI { + if (apiServerUrl == null) { + throw IllegalStateException("Provide API Server URL") + } + + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + pendingLogin = CompletableDeferred() + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port) + ) + + val request = authFlow.startAuthFlow() + + CoroutineScope(Dispatchers.IO).launch { + try { + val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) + if (params == null) { + pendingLogin?.completeExceptionally(SsoLoginException.Timeout) + notifyLoginCancelled() + return@launch + } + + val token: SSOToken = authFlow.handleCallback(params) + currentToken = token + pendingLogin?.complete(token) + + } catch (e: Exception) { + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "OpenShift login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + private suspend fun cancelLogin() { + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + private fun notifyLoginCancelled() { + Notifications.Bus.notify( + Notification( + "OpenShift Authentication", + "Login cancelled", + "You closed the browser or the login timed out.", + NotificationType.INFORMATION + ) + ) + } + + override suspend fun getValidToken(): SSOToken? { + val token = currentToken ?: return null + if (!token.isExpired()) return token + + logout() + return null + } + + override suspend fun logout() { + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + override fun isLoggedIn(): Boolean = currentToken != null + + override fun currentAccount(): String? = currentToken?.accountLabel +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt new file mode 100644 index 00000000..c00aefee --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -0,0 +1,197 @@ +/* + * 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 + */ +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + +package com.redhat.devtools.gateway.auth.session + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.components.Service +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.RedHatAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.config.AuthConfig +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.oidc.OidcProviderMetadataResolver +import com.redhat.devtools.gateway.auth.server.CallbackServer +import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer +import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder +import com.redhat.devtools.gateway.auth.server.ServerConfigProvider +import kotlinx.coroutines.* +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean + +const val LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class RedHatAuthSessionManager : AuthSessionManager { + + private val tokenStorage: SecureTokenStorage = + JBPasswordSafeTokenStorage() + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_REDHAT) + } + + private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private val authConfig = AuthConfig() + + private val providerMetadata = runBlocking { + OidcProviderMetadataResolver(authConfig.authUrl).resolve() + } + + private lateinit var authFlow: RedHatAuthCodeFlow + + private val listeners = mutableSetOf() + private var currentToken: SSOToken? = null + + private val loginInProgress = AtomicBoolean(false) + + fun isLoginInProgress(): Boolean = loginInProgress.get() + + fun addListener(listener: AuthSessionListener) { + listeners += listener + } + + fun removeListener(listener: AuthSessionListener) { + listeners -= listener + } + + private fun notifyChanged() { + listeners.forEach { it.sessionChanged() } + } + + /** + * Called once on plugin startup. + */ + override suspend fun initialize() { + notifyChanged() + } + + private var pendingLogin: CompletableDeferred? = null + + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin + ?: throw IllegalStateException("Login was not started") + + return try { + withTimeout(timeoutMs) { + deferred.await() + } + } catch (_: TimeoutCancellationException) { + throw SsoLoginException.Timeout + } + } + + /** + * Starts the login process and returns browser URL. + */ + override suspend fun startLogin(apiServerUrl: String?): URI { + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + pendingLogin = CompletableDeferred() + + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + + authFlow = RedHatAuthCodeFlow( + clientId = authConfig.clientId, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + providerMetadata = providerMetadata + ) + + val request = authFlow.startAuthFlow() + CoroutineScope(Dispatchers.IO).launch { + try { + val params = callbackServer.awaitCallback(LOGIN_TIMEOUT_MS) + if (params == null) { + pendingLogin?.completeExceptionally( + SsoLoginException.Timeout + ) + notifyLoginCancelled() + + return@launch + } + + val token = authFlow.handleCallback(params) + currentToken = token + + pendingLogin?.complete(token) + } catch (e: Exception) { + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "SSO login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + private suspend fun cancelLogin() { + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + private fun notifyLoginCancelled() { + Notifications.Bus.notify( + Notification( + "RedHat Authentication", + "Login cancelled", + "You closed the browser or the login timed out.", + NotificationType.INFORMATION + ) + ) + } + + /** + * Returns a valid (non-expired) token or null. + * Refreshes automatically if possible. + */ + override suspend fun getValidToken(): SSOToken? { + val token = currentToken ?: return null + + if (!token.isExpired()) { + return token + } + + logout() + return null + } + + override suspend fun logout() { + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + override fun isLoggedIn(): Boolean = currentToken != null + + override fun currentAccount(): String? = currentToken?.accountLabel +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt new file mode 100644 index 00000000..1f03d131 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt @@ -0,0 +1,19 @@ +/* + * 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.session + +import kotlin.Exception + +sealed class SsoLoginException : Exception() { + object Timeout : SsoLoginException() + data class Failed(val reason: String) : SsoLoginException() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt index c84c89bd..14856a0d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt @@ -15,6 +15,12 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.apis.AuthorizationV1Api +import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReview +import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReviewSpec +import io.kubernetes.client.openapi.models.V1ResourceAttributes + class Projects(private val client: ApiClient) { @Throws(ApiException::class) fun list(): List<*> { @@ -35,4 +41,27 @@ class Projects(private val client: ApiClient) { return true } + /** + * Check if the token is valid and usable for the namespace. + * Works for user OAuth tokens and pipeline SA tokens. + */ + @Throws(ApiException::class) + fun isAuthenticatedAlternative(): Boolean { + val api = AuthorizationV1Api(client) + + val review = V1SelfSubjectAccessReview().apply { + spec = V1SelfSubjectAccessReviewSpec().apply { + resourceAttributes = V1ResourceAttributes().apply { + verb = "get" + resource = "namespaces" + } + } + } + + val response = api + .createSelfSubjectAccessReview(review) + .execute() + + return response.status?.allowed == true + } } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 2989a358..cf745f2b 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 @@ -11,6 +11,8 @@ */ package com.redhat.devtools.gateway.view.steps +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.invokeLater import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger @@ -25,16 +27,22 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate +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.Projects import com.redhat.devtools.gateway.settings.DevSpacesSettings -import com.redhat.devtools.gateway.util.message -import com.redhat.devtools.gateway.view.ui.* +import com.redhat.devtools.gateway.view.ui.Dialogs +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 kotlinx.coroutines.* import java.awt.event.ItemEvent import java.awt.event.KeyAdapter @@ -42,6 +50,9 @@ import java.awt.event.KeyEvent import javax.swing.JTextField import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import io.kubernetes.client.openapi.ApiClient class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, @@ -58,6 +69,10 @@ class DevSpacesServerStepView( private val updateKubeconfigCheckbox = JBCheckBox("Save configuration") + private val sessionManager = + ApplicationManager.getApplication() + .getService(RedHatAuthSessionManager::class.java) + private var tfToken = JBTextField() .apply { document.addDocumentListener(onTokenChanged()) @@ -75,6 +90,19 @@ class DevSpacesServerStepView( (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) } + private enum class AuthMethod { + TOKEN, + OPENSHIFT, + SSO + } + + private var authMethod: AuthMethod = AuthMethod.TOKEN + + private fun updateAuthUiState() { + tfToken.isEnabled = authMethod == AuthMethod.TOKEN + enableNextButton?.invoke() + } + override val component = panel { row { label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { @@ -84,9 +112,43 @@ class DevSpacesServerStepView( row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.server")) { cell(tfServer).align(Align.FILL) } + + buttonsGroup { + row("Authentication") { + radioButton("Token") + .applyToComponent { + isSelected = true + toolTipText = "Use a manually provided token from kubeconfig or oc login" + addActionListener { + authMethod = AuthMethod.TOKEN + updateAuthUiState() + } + } + + radioButton("OpenShift OAuth") + .applyToComponent { + addActionListener { + toolTipText = "Authenticate via OpenShift Authenticator (oc login --web)" + authMethod = AuthMethod.OPENSHIFT + updateAuthUiState() + } + } + + radioButton("Red Hat SSO (Sandbox)") + .applyToComponent { + addActionListener { + toolTipText = "Authenticate via Red Hat SSO token (Sandbox only)" + authMethod = AuthMethod.SSO + updateAuthUiState() + } + } + } + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { cell(tfToken).align(Align.FILL) } + row("") { cell(updateKubeconfigCheckbox).applyToComponent { isOpaque = false @@ -106,6 +168,7 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() + updateAuthUiState() } private fun onClusterSelected(event: ItemEvent) { @@ -179,41 +242,132 @@ class DevSpacesServerStepView( override fun onNext(): Boolean { val selectedCluster = tfServer.selectedItem as? Cluster ?: return false val server = selectedCluster.url - val token = tfToken.text - val client = OpenShiftClientFactory(KubeConfigUtils).create(server, token.toCharArray()) var success = false stopKubeconfigMonitor() ProgressManager.getInstance().runProcessWithProgressSynchronously( { + val indicator = ProgressManager.getInstance().progressIndicator + try { - val indicator = ProgressManager.getInstance().progressIndicator - saveKubeconfig(tfServer.selectedItem as? Cluster?, tfToken.text, indicator) - indicator.text = "Checking connection..." - Projects(client).isAuthenticated() + indicator.text = "Connecting to cluster..." + + when (authMethod) { + AuthMethod.TOKEN -> { + indicator.text = "Validating token..." + + val token = tfToken.text + + val client = createValidatedApiClient(server, token, + "Authentication failed: invalid server URL or token.") + + saveKubeconfig(selectedCluster, token, indicator) + devSpacesContext.client = client + } + + AuthMethod.OPENSHIFT -> { + indicator.text = "Authenticating with Openshift..." + + val finalToken = runBlocking { + val openshiftSSessionManager = OpenShiftAuthSessionManager() + val uri = openshiftSSessionManager.startLogin(selectedCluster.url) + BrowserUtil.browse(uri) + + indicator.text = "Waiting for you to complete login in your browser..." + indicator.checkCanceled() + + indicator.text = "Obtaining OpenShift access..." + val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + + TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient(server, finalToken.accessToken, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired.") + + tfToken.text = finalToken.accessToken + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + + AuthMethod.SSO -> { + indicator.text = "Authenticating with Red Hat..." + + val finalToken = runBlocking { + val uri = sessionManager.startLogin() + BrowserUtil.browse(uri) + + indicator.text = "Waiting for you to complete login in your browser..." + indicator.checkCanceled() + + val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + indicator.text = "Obtaining OpenShift access..." + + val sandboxAuth = SandboxClusterAuthProvider() + sandboxAuth.authenticate(ssoToken) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient(server, finalToken.accessToken, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster.") + + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } + devSpacesContext.client = client + } + } + success = true } catch (e: Exception) { - Dialogs.error(e.message(), "Connection failed") + Dialogs.error( + e.message ?: "Unable to connect to the cluster", + "Connection Failed" + ) throw e } }, - "Checking Connection...", + "Connecting to OpenShift...", true, null ) if (success) { - settings.save(tfServer.selectedItem as? Cluster) - devSpacesContext.client = client + settings.save(selectedCluster) } return success } + @Throws(IllegalArgumentException::class) + private fun createValidatedApiClient( + server: String, + token: String, + errorMessage: String? = null + ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) + .create(server, token.toCharArray()) + .also { client -> + require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + } + override fun isNextEnabled(): Boolean { - return tfServer.selectedItem != null - && tfToken.text.isNotEmpty() + if (tfServer.selectedItem == null) return false + + return when (authMethod) { + AuthMethod.TOKEN -> tfToken.text.isNotBlank() + AuthMethod.OPENSHIFT, AuthMethod.SSO -> true + } } private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { From 2c1fd4f7e5ad04a0732104c2965e005f6f5382ee Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Wed, 28 Jan 2026 14:12:54 +0100 Subject: [PATCH 02/32] feat: Gateway: CRW-8927 - Simplify login in to the OCP cluster from the Gateway plugin This PR adds a possibility to authorize on... - ...an OpenShift cluster via Username/Password Signed-off-by: Victor Rubezhny Assisted-by: OpenAI ChatGPT --- .../gateway/DevSpacesConnectionProvider.kt | 29 +- .../gateway/auth/code/AuthCodeFlow.kt | 10 +- .../auth/code/OpenShiftAuthCodeFlow.kt | 168 +++++- .../gateway/auth/code/RedHatAuthCodeFlow.kt | 12 +- .../gateway/auth/server/CallbackServer.kt | 2 +- .../auth/server/OAuthCallbackServer.kt | 1 + .../auth/session/AuthSessionManager.kt | 6 +- .../session/OpenShiftAuthSessionManager.kt | 48 +- .../auth/session/RedHatAuthSessionManager.kt | 17 +- .../gateway/auth/tls/CapturingTrustManager.kt | 30 + .../auth/tls/DefaultTlsTrustManager.kt | 152 +++++ .../gateway/auth/tls/KeyStoreUtils.kt | 31 + .../gateway/auth/tls/KubeConfigCertEncoder.kt | 28 + .../gateway/auth/tls/KubeConfigTlsUtils.kt | 42 ++ .../gateway/auth/tls/KubeConfigTlsWriter.kt | 78 +++ .../devtools/gateway/auth/tls/PemUtils.kt | 80 +++ .../gateway/auth/tls/PersistentKeyStore.kt | 44 ++ .../gateway/auth/tls/SessionTlsTrustStore.kt | 27 + .../gateway/auth/tls/SslContextFactory.kt | 81 +++ .../devtools/gateway/auth/tls/TlsContext.kt | 20 + .../devtools/gateway/auth/tls/TlsProbe.kt | 28 + .../auth/tls/TlsServerCertificateInfo.kt | 21 + .../gateway/auth/tls/TlsTrustDecision.kt | 23 + .../gateway/auth/tls/TlsTrustManager.kt | 26 + .../gateway/auth/tls/TlsTrustProblem.kt | 17 + .../auth/tls/TlsTrustRejectedException.kt | 15 + .../gateway/auth/tls/TlsTrustScope.kt | 17 + .../auth/tls/ui/TLSTrustDecisionHandler.kt | 101 ++++ .../auth/tls/ui/UiTlsDecisionAdapter.kt | 41 ++ .../gateway/kubeconfig/KubeConfigEntries.kt | 11 + .../gateway/kubeconfig/KubeConfigUpdate.kt | 155 +++++ .../gateway/kubeconfig/KubeConfigUtils.kt | 23 +- .../devtools/gateway/openshift/Cluster.kt | 14 +- .../openshift/OpenShiftClientFactory.kt | 207 +++++-- .../devtools/gateway/openshift/Utils.kt | 21 + .../gateway/view/SelectClusterDialog.kt | 67 ++ .../view/steps/DevSpacesServerStepView.kt | 571 ++++++++++++++---- .../gateway/view/steps/DevSpacesWizardStep.kt | 4 +- .../messages/DevSpacesBundle.properties | 21 + .../kubeconfig/KubeConfigMonitorTest.kt | 10 +- .../devtools/gateway/openshift/ClusterTest.kt | 119 +++- 41 files changed, 2211 insertions(+), 207 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KeyStoreUtils.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigCertEncoder.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PersistentKeyStore.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustDecision.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustProblem.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustRejectedException.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustScope.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 6b992489..ea11a7c7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * 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/ @@ -28,11 +28,10 @@ import com.redhat.devtools.gateway.openshift.isUnauthorized import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.util.isCancellationException 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.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.* import java.util.concurrent.CancellationException import javax.swing.JComponent import javax.swing.Timer @@ -48,14 +47,22 @@ private const val DW_NAME = "dwName" */ class DevSpacesConnectionProvider : GatewayConnectionProvider { - private var clientFactory: OpenShiftClientFactory? = null - @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UnstableApiUsage") override suspend fun connect( parameters: Map, requestor: ConnectionRequestor ): GatewayConnectionHandle? { + val ctx = DevSpacesContext() + + val confirmed = withContext(Dispatchers.Main) { + SelectClusterDialog(ctx).showAndConnect() + } + + if (!confirmed) { + return null + } + return suspendCancellableCoroutine { cont -> ProgressManager.getInstance().runProcessWithProgressSynchronously( { @@ -194,7 +201,6 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { indicator.update(message = "Initializing Kubernetes connection…") val factory = OpenShiftClientFactory(KubeConfigUtils) - this.clientFactory = factory ctx.client = factory.create() indicator.update(message = "Fetching workspace “$dwName” from namespace “$dwNamespace”…") @@ -244,12 +250,10 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { private fun handleUnauthorizedError(err: ApiException): Boolean { if (!err.isUnauthorized()) return false - val tokenNote = if (clientFactory?.isTokenAuth() == true) - "\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file." - else "" - Dialogs.error( - "Your session has expired.\nPlease log in again to continue.$tokenNote", + "Your session has expired.\n" + + "Please authenticate again to continue.\n\n" + + "If you are using token-based authentication, update your token in the kubeconfig file.", "Authentication Required" ) return true @@ -274,5 +278,4 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { runnable.invoke() }.start() } - } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt index a83b8e53..6350fe94 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt @@ -14,7 +14,6 @@ package com.redhat.devtools.gateway.auth.code import java.net.URI import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce -import com.redhat.devtools.gateway.auth.server.Parameters import kotlinx.serialization.Serializable /** @@ -59,10 +58,15 @@ data class TokenModel( val serviceAccount: String? = null ) +typealias Parameters = Map + interface AuthCodeFlow { - /** Starts the auth flow and returns the info to open the browser */ + /** Starts the 2-step auth flow and returns the info to open the browser */ suspend fun startAuthFlow(): AuthCodeRequest - /** Handles the redirect/callback and returns the final tokens */ + /** Handles the redirect/callback and returns the final tokens for the 2-step auth flow */ suspend fun handleCallback(parameters: Parameters): SSOToken + + /** Single-step auth flow - exchanges username/password to the final token */ + suspend fun login(parameters: Parameters): SSOToken } 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 971cfe0f..ce6cbe97 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,17 +18,18 @@ import com.nimbusds.oauth2.sdk.id.State import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.Nonce -import com.redhat.devtools.gateway.auth.server.Parameters import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.net.URI +import java.net.URLDecoder import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.util.* +import javax.net.ssl.SSLContext /** * Canonical OpenShift OAuth flow (PKCE + Authorization Code), mimics `oc login --web`. @@ -36,7 +37,8 @@ import java.util.* */ class OpenShiftAuthCodeFlow( private val apiServerUrl: String, // Cluster API server - private val redirectUri: URI // Local callback server URI + private val redirectUri: URI?, // Local callback server URI (optional) + private val sslContext: SSLContext ) : AuthCodeFlow { private lateinit var codeVerifier: CodeVerifier @@ -46,10 +48,19 @@ class OpenShiftAuthCodeFlow( private val json = Json { ignoreUnknownKeys = true } - private val httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() + private fun discoveryClient(): HttpClient = + HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + private fun noRedirectClient(): HttpClient = + HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NEVER) + .build() @Serializable private data class OAuthMetadata( @@ -66,13 +77,14 @@ class OpenShiftAuthCodeFlow( * Discover OAuth endpoints from the cluster. */ private suspend fun discoverOAuthMetadata(): OAuthMetadata { - val url = "$apiServerUrl/.well-known/oauth-authorization-server" + val client = discoveryClient() + val request = HttpRequest.newBuilder() - .uri(URI.create(url)) + .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) .GET() .build() - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() !in 200..299) { error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") } @@ -110,14 +122,21 @@ class OpenShiftAuthCodeFlow( override suspend fun handleCallback(parameters: Parameters): SSOToken { val code: String = parameters["code"] ?: error("Missing 'code' parameter in callback") + return exchangeCodeForToken(code) + } + + private fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=" + + URLEncoder.encode(v, StandardCharsets.UTF_8) + } + + private suspend fun exchangeCodeForToken(code: String): SSOToken { + val httpClient = discoveryClient() + val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("openshift-cli-client:".toByteArray(StandardCharsets.UTF_8)) - fun encodeForm(vararg pairs: Pair): String = - pairs.joinToString("&") { (k, v) -> - "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" - } - val form = encodeForm( "grant_type" to "authorization_code", "client_id" to "openshift-cli-client", @@ -140,13 +159,130 @@ class OpenShiftAuthCodeFlow( } val token = json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) - val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + val expiresAt = + if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null return SSOToken( accessToken = token.accessToken, - idToken = "", // OpenShift does not issue id_token + idToken = "", accountLabel = "openshift-user", 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() + codeVerifier = CodeVerifier() + state = State() + + val httpClient = noRedirectClient() + + val redirectUri = URI( + metadata.tokenEndpoint.replace( + "/oauth/token", + "/oauth/token/implicit" + ) + ) + + val authorizeUri = AuthorizationRequest.Builder( + ResponseType.CODE, + ClientID("openshift-challenging-client") + ) + .endpointURI(URI(metadata.authorizationEndpoint)) + .redirectionURI(redirectUri) + .build() + .toURI() + + 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.send(request, HttpResponse.BodyHandlers.discarding()) + + // 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.send(request, HttpResponse.BodyHandlers.discarding()) + } + + if (response.statusCode() !in listOf(302, 303)) { + error("Authorization failed: ${response.statusCode()}") + } + + val location = response.headers().firstValue("Location") + .orElseThrow { error("Missing redirect Location header") } + val redirectedUri = URI(location) + val query = redirectedUri.query ?: error("Missing query in redirect") + val params = query.split("&") + .map { it.split("=", limit = 2) } + .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + + val code = params["code"] ?: error("Authorization code not found in redirect") + + val token = exchangeCodeForTokenWithBasicAuth(httpClient, code = code, redirectUri = redirectUri) + + return SSOToken( + accessToken = token.accessToken, + idToken = token.idToken, + accountLabel = username, + expiresAt = token.expiresAt + ) + } + + private suspend fun exchangeCodeForTokenWithBasicAuth( + httpClient: HttpClient, + code: String, + redirectUri: URI + ): SSOToken { + val clientAuth = "Basic " + Base64.getEncoder() + .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) + + val form = encodeForm( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + val request = HttpRequest.newBuilder() + .uri(URI(metadata.tokenEndpoint)) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuth) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + error("Token exchange failed: ${response.statusCode()} ${response.body()}") + } + + val token = json.decodeFromString( + AccessTokenResponseJson.serializer(), + response.body() + ) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + + return SSOToken( + accessToken = token.accessToken, + idToken = "", + accountLabel = "", + expiresAt = expiresAt + ) + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt index a254aa53..d5c8e517 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -22,7 +22,6 @@ import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.AuthenticationRequest import com.nimbusds.openid.connect.sdk.Nonce import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata -import com.redhat.devtools.gateway.auth.server.Parameters import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -34,6 +33,7 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.util.* +import javax.net.ssl.SSLContext /** * RedHat SSO OAuth Flow. @@ -42,7 +42,8 @@ import java.util.* class RedHatAuthCodeFlow( private val clientId: String, private val redirectUri: URI, - private val providerMetadata: OIDCProviderMetadata + private val providerMetadata: OIDCProviderMetadata, + private val sslContext: SSLContext ) : AuthCodeFlow { private lateinit var codeVerifier: CodeVerifier @@ -140,4 +141,11 @@ class RedHatAuthCodeFlow( accountLabel = accountLabel ) } + + override suspend fun login(parameters: Parameters): SSOToken = + error( + "Direct login is not supported for Red Hat SSO authentication. " + + "This flow requires browser-based authentication via startAuthFlow(), " + + "followed by token exchange with the Sandbox API." + ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt index 3485e510..8b5e17c5 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt @@ -11,7 +11,7 @@ */ package com.redhat.devtools.gateway.auth.server -typealias Parameters = Map +import com.redhat.devtools.gateway.auth.code.Parameters interface CallbackServer { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt index d43c7b3a..baa8753a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.auth.server +import com.redhat.devtools.gateway.auth.code.Parameters import com.redhat.devtools.gateway.auth.config.ServerConfig import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt index d8a47d02..748d4322 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.auth.session import com.redhat.devtools.gateway.auth.code.SSOToken import java.net.URI +import javax.net.ssl.SSLContext interface AuthSessionManager { @@ -20,7 +21,10 @@ interface AuthSessionManager { suspend fun initialize() /** Starts login and returns browser URL */ - suspend fun startLogin(apiServerUrl: String? = null): URI + suspend fun startLogin(apiServerUrl: String? = null, sslContext: SSLContext): URI + + /** Starts login using the given credentials and returns a valid token */ + suspend fun loginWithCredentials(apiServerUrl: String, username: String, password: String, sslContext: SSLContext): SSOToken /** Returns a valid (non-expired) token or null. Refreshes automatically if possible. */ suspend fun getValidToken(): SSOToken? diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt index ad52443a..ca7a75df 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -17,22 +17,23 @@ import com.intellij.notification.Notifications import com.intellij.openapi.components.Service import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.Parameters import com.redhat.devtools.gateway.auth.code.SSOToken import com.redhat.devtools.gateway.auth.code.SecureTokenStorage import com.redhat.devtools.gateway.auth.config.AuthType import com.redhat.devtools.gateway.auth.server.CallbackServer import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer -import com.redhat.devtools.gateway.auth.server.Parameters import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder import com.redhat.devtools.gateway.auth.server.ServerConfigProvider import kotlinx.coroutines.* import java.net.URI import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.SSLContext const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L @Service(Service.Level.APP) -class OpenShiftAuthSessionManager : AuthSessionManager { +class OpenShiftAuthSessionManager() : AuthSessionManager { private val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() @@ -76,7 +77,7 @@ class OpenShiftAuthSessionManager : AuthSessionManager { } } - override suspend fun startLogin(apiServerUrl: String?): URI { + override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { if (apiServerUrl == null) { throw IllegalStateException("Provide API Server URL") } @@ -93,8 +94,9 @@ class OpenShiftAuthSessionManager : AuthSessionManager { val port = callbackServer.start() authFlow = OpenShiftAuthCodeFlow( - apiServerUrl, - redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port) + apiServerUrl = apiServerUrl, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + sslContext = sslContext ) val request = authFlow.startAuthFlow() @@ -165,4 +167,40 @@ class OpenShiftAuthSessionManager : AuthSessionManager { override fun isLoggedIn(): Boolean = currentToken != null override fun currentAccount(): String? = currentToken?.accountLabel + + override suspend fun loginWithCredentials( + apiServerUrl: String, + username: String, + password: String, + sslContext: SSLContext + ): SSOToken { + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + try { + notifyChanged() + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl = apiServerUrl, + redirectUri = URI("$apiServerUrl/oauth/token/implicit"), + sslContext = sslContext + ) + + val token = authFlow.login( + mapOf( + "username" to username, + "password" to password + ) + ) + + currentToken = token + return token + } catch (e: Exception) { + throw SsoLoginException.Failed(e.message ?: "OpenShift credential login failed") + } finally { + loginInProgress.set(false) + notifyChanged() + } + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index c00aefee..fe7453e4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -31,11 +31,12 @@ import com.redhat.devtools.gateway.auth.server.ServerConfigProvider import kotlinx.coroutines.* import java.net.URI import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.SSLContext const val LOGIN_TIMEOUT_MS = 2 * 60_000L @Service(Service.Level.APP) -class RedHatAuthSessionManager : AuthSessionManager { +class RedHatAuthSessionManager(): AuthSessionManager { private val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() @@ -98,7 +99,7 @@ class RedHatAuthSessionManager : AuthSessionManager { /** * Starts the login process and returns browser URL. */ - override suspend fun startLogin(apiServerUrl: String?): URI { + override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { if (!loginInProgress.compareAndSet(false, true)) { throw IllegalStateException("Login already in progress") } @@ -114,7 +115,8 @@ class RedHatAuthSessionManager : AuthSessionManager { authFlow = RedHatAuthCodeFlow( clientId = authConfig.clientId, redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), - providerMetadata = providerMetadata + providerMetadata = providerMetadata, + sslContext = sslContext ) val request = authFlow.startAuthFlow() @@ -153,6 +155,15 @@ class RedHatAuthSessionManager : AuthSessionManager { } } + override suspend fun loginWithCredentials( + apiServerUrl: String, + username: String, + password: String, + sslContext: SSLContext + ): SSOToken { + error("Not supported") + } + private suspend fun cancelLogin() { loginInProgress.set(false) notifyChanged() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt new file mode 100644 index 00000000..8f596357 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt @@ -0,0 +1,30 @@ +/* + * 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 + +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +class CapturingTrustManager : X509TrustManager { + + @Volatile + var serverCertificateChain: Array? = null + private set + + override fun checkServerTrusted(chain: Array, authType: String) { + serverCertificateChain = chain + } + + override fun checkClientTrusted(chain: Array, authType: String) {} + + override fun getAcceptedIssuers(): Array = emptyArray() +} 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 new file mode 100644 index 00000000..f1376335 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -0,0 +1,152 @@ +/* + * 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 + +import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import io.kubernetes.client.util.KubeConfig +import java.net.URI +import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException + +class DefaultTlsTrustManager( + private val kubeConfigProvider: () -> List, + private val kubeConfigWriter: (KubeConfigNamedCluster, List) -> Unit, + private val sessionTrustStore: SessionTlsTrustStore, + private val persistentKeyStore: PersistentKeyStore +) : TlsTrustManager { + + override suspend fun ensureTrusted( + serverUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + ): TlsContext { + + val serverUri = URI(serverUrl) + + // 1️⃣ Locate kubeconfig cluster + val namedCluster = + KubeConfigTlsUtils.findClusterByServer( + serverUrl, + kubeConfigProvider() + ) + + // 2️⃣ insecure-skip-tls-verify + if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { + return SslContextFactory.insecure() + } + + // 3️⃣ Load all trusted certs (kubeconfig + session + persistent) + val trustedCerts = mutableListOf() + + namedCluster?.let { + trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } + + trustedCerts += sessionTrustStore.get(serverUrl) + + // load persistent keystore cert for this host only + val keyStore = persistentKeyStore.loadOrCreate() + val persistentAlias = "host:${serverUri.host}" + + val persistentCert = keyStore.getCertificate(persistentAlias) + if (persistentCert is X509Certificate) { + trustedCerts += persistentCert + } + + // 4️⃣ If we have trusted certs — try normal handshake first + if (trustedCerts.isNotEmpty()) { + try { + val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) + TlsProbe.connect(serverUri, tlsContext.sslContext) + return tlsContext + } catch (e: SSLHandshakeException) { + // Certificate changed or invalid → continue to capture + } + } + + // 5️⃣ Capture server certificate chain + val captureContext = SslContextFactory.captureOnly() + + try { + TlsProbe.connect(serverUri, captureContext.sslContext) + return captureContext // should not normally succeed + } catch (e: SSLHandshakeException) { + + val chain = (captureContext.trustManager as? CapturingTrustManager) + ?.serverCertificateChain + ?.toList() + ?: throw e + + val trustAnchor = chain.first() + + val problem = + if (trustedCerts.isEmpty()) + TlsTrustProblem.UNTRUSTED_CERTIFICATE + else + TlsTrustProblem.CERTIFICATE_CHANGED + + val info = TlsServerCertificateInfo( + serverUrl = serverUrl, + certificateChain = chain, + fingerprintSha256 = sha256Fingerprint(trustAnchor), + problem = problem + ) + + // 6️⃣ Ask UI layer + val decision = decisionHandler(info) + + if (!decision.trusted) { + throw TlsTrustRejectedException() + } + + // 7️⃣ Persist based on scope + when (decision.scope) { + TlsTrustScope.SESSION_ONLY -> { + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + } + + TlsTrustScope.PERMANENT -> { + + // session + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + + // kubeconfig + if (namedCluster != null) { + kubeConfigWriter(namedCluster, listOf(trustAnchor)) + } + + // persistent keystore (host-scoped) + KeyStoreUtils.addCertificate( + keyStore, + persistentAlias, + trustAnchor + ) + persistentKeyStore.save(keyStore) + } + + null -> error("Trusted decision without scope") + } + + // 8️⃣ Return final trusted SSLContext + val finalCerts = (trustedCerts + trustAnchor) + .distinctBy { it.serialNumber } + + return SslContextFactory.fromTrustedCerts(finalCerts) + } + } + + /** Private helper: SHA-256 fingerprint of a certificate */ + private fun sha256Fingerprint(cert: X509Certificate): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(cert.encoded) + return digest.joinToString(":") { "%02X".format(it) } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KeyStoreUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KeyStoreUtils.kt new file mode 100644 index 00000000..1b00924c --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KeyStoreUtils.kt @@ -0,0 +1,31 @@ +/* + * 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 + +import java.security.KeyStore +import java.security.cert.X509Certificate + +internal object KeyStoreUtils { + + fun createEmpty(): KeyStore = + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + } + + fun addCertificate( + keyStore: KeyStore, + alias: String, + certificate: X509Certificate + ) { + keyStore.setCertificateEntry(alias, certificate) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigCertEncoder.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigCertEncoder.kt new file mode 100644 index 00000000..215e5427 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigCertEncoder.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import java.security.cert.X509Certificate +import java.util.Base64 + +object KubeConfigCertEncoder { + + /** + * Encodes a certificate exactly as expected by kubeconfig's + * `certificate-authority-data` field. + */ + fun encode(certificate: X509Certificate): String { + val pem = PemUtils.toPem(certificate) + return Base64.getEncoder() + .encodeToString(pem.toByteArray(Charsets.UTF_8)) + } +} 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 new file mode 100644 index 00000000..3d002dfb --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.kt @@ -0,0 +1,42 @@ +/* + * 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 + +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 + +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 caData = namedCluster.cluster.certificateAuthorityData ?: return emptyList() + val decoded = Base64.getDecoder().decode(caData) + val factory = CertificateFactory.getInstance("X.509") + + return factory + .generateCertificates(decoded.inputStream()) + .filterIsInstance() + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt new file mode 100644 index 00000000..67bc2a25 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsWriter.kt @@ -0,0 +1,78 @@ +/* + * 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 + +import com.redhat.devtools.gateway.kubeconfig.BlockStyleFilePersister +import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path +import com.redhat.devtools.gateway.openshift.Utils +import java.security.cert.X509Certificate + +object KubeConfigTlsWriter { + + fun write( + namedCluster: KubeConfigNamedCluster, + certificates: List + ) { + if (certificates.isEmpty()) return + + val encodedCa = KubeConfigCertEncoder.encode(certificates.first()) + + val allConfigs = KubeConfigUtils.getAllConfigs( + KubeConfigUtils.getAllConfigFiles() + ) + + // Find the kubeconfig that actually contains this cluster + val config = allConfigs.firstOrNull { kubeConfig -> + kubeConfig.clusters?.any { entry -> + val map = entry as? Map<*, *> ?: return@any false + map["name"] == namedCluster.name + } == true + } ?: return + + val clusterEntry = config.clusters + ?.firstOrNull { entry -> + val map = entry as? Map<*, *> ?: return@firstOrNull false + map["name"] == namedCluster.name + } + as? MutableMap<*, *> + ?: return + + // Write certificate-authority-data + Utils.setValue( + clusterEntry, + encodedCa, + arrayOf("cluster", "certificate-authority-data") + ) + + // Remove insecure flag if present + removeInsecureSkipTlsVerify(clusterEntry) + + // Persist + val file = config.path?.toFile() ?: return + val persister = BlockStyleFilePersister(file) + persister.save( + config.contexts, + config.clusters, + config.users, + config.preferences, + config.currentContext + ) + } + + @Suppress("UNCHECKED_CAST") + private fun removeInsecureSkipTlsVerify(clusterEntry: MutableMap<*, *>) { + val clusterMap = clusterEntry["cluster"] as? MutableMap<*, *> ?: return + (clusterMap as MutableMap).remove("insecure-skip-tls-verify") + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt new file mode 100644 index 00000000..2ef1aacb --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt @@ -0,0 +1,80 @@ +/* + * 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 + +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.* + +object PemUtils { + + fun toPem(certificate: X509Certificate): String { + val base64 = Base64.getMimeEncoder(64, "\n".toByteArray()) + .encodeToString(certificate.encoded) + + return buildString { + append("-----BEGIN CERTIFICATE-----\n") + append(base64) + append("\n-----END CERTIFICATE-----\n") + } + } + + fun parsePrivateKey(pemOrBase64: String): PrivateKey { + val normalized = normalizePem(pemOrBase64) + + val cleaned = normalized + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val decoded = Base64.getDecoder().decode(cleaned) + + return try { + // Try PKCS#8 first + val spec = PKCS8EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePrivate(spec) + } catch (_: Exception) { + // Try EC + try { + val spec = PKCS8EncodedKeySpec(decoded) + KeyFactory.getInstance("EC").generatePrivate(spec) + } catch (e: Exception) { + throw IllegalArgumentException("Unsupported private key format", e) + } + } + } + + private fun normalizePem(input: String): String { + val trimmed = input.trim() + + return if (!trimmed.contains("BEGIN")) { + // It's base64 from kubeconfig → decode to PEM + String(Base64.getDecoder().decode(trimmed)) + } else { + trimmed + } + } + + fun parseCertificate(pemOrBase64: String): X509Certificate { + val normalized = normalizePem(pemOrBase64) + + val factory = CertificateFactory.getInstance("X.509") + return factory.generateCertificate( + normalized.byteInputStream() + ) as X509Certificate + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PersistentKeyStore.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PersistentKeyStore.kt new file mode 100644 index 00000000..a51295ec --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PersistentKeyStore.kt @@ -0,0 +1,44 @@ +/* + * 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 + +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore + +class PersistentKeyStore( + private val path: Path, + private val password: CharArray = CharArray(0) +) { + + fun loadOrCreate(): KeyStore { + val keyStore = KeyStore.getInstance("PKCS12") + + if (Files.exists(path)) { + Files.newInputStream(path).use { + keyStore.load(it, password) + } + } else { + keyStore.load(null, password) + } + + return keyStore + } + + fun save(keyStore: KeyStore) { + Files.createDirectories(path.parent) + + Files.newOutputStream(path).use { + keyStore.store(it, password) + } + } +} 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 new file mode 100644 index 00000000..18a1e4c9 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SessionTlsTrustStore.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import java.security.cert.X509Certificate +import java.util.concurrent.ConcurrentHashMap + +class SessionTlsTrustStore { + + private val trusted = ConcurrentHashMap>() + + fun get(serverUrl: String): List = + trusted[serverUrl].orEmpty() + + fun put(serverUrl: String, certificates: List) { + trusted[serverUrl] = certificates + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt new file mode 100644 index 00000000..13247f1e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt @@ -0,0 +1,81 @@ +/* + * 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 + +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +object SslContextFactory { + + fun empty(): TlsContext = + fromTrustedCerts(emptyList()) + + fun insecure(): TlsContext { + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(c: Array, a: String) {} + override fun checkServerTrusted(c: Array, a: String) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustAll), SecureRandom()) + } + + return TlsContext(sslContext, trustAll) + } + + fun fromTrustedCerts(certs: List): TlsContext { + val keyStore = KeyStoreUtils.createEmpty() + + certs.forEachIndexed { idx, cert -> + KeyStoreUtils.addCertificate( + keyStore, + "trusted-$idx-${cert.serialNumber}", + cert + ) + } + + val tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() + ) + tmf.init(keyStore) + + val trustManager = tmf.trustManagers + .filterIsInstance() + .first() + + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, tmf.trustManagers, SecureRandom()) + } + + return TlsContext(sslContext, trustManager) + } + + fun captureOnly(): TlsContext { + val capturingTrustManager = CapturingTrustManager() + + val sslContext = SSLContext.getInstance("TLS").apply { + init( + null, + arrayOf(capturingTrustManager), + SecureRandom() + ) + } + + return TlsContext(sslContext, capturingTrustManager) + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt new file mode 100644 index 00000000..eab5f01c --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsContext.kt @@ -0,0 +1,20 @@ +/* + * 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 + +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +data class TlsContext( + val sslContext: SSLContext, + val trustManager: X509TrustManager +) \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt new file mode 100644 index 00000000..ee3dcc0c --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import java.net.URI +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket + +object TlsProbe { + + fun connect(serverUri: URI, sslContext: SSLContext) { + val socketFactory = sslContext.socketFactory + val port = if (serverUri.port != -1) serverUri.port else 443 + + (socketFactory.createSocket(serverUri.host, port) as SSLSocket).use { socket -> + socket.startHandshake() + } + } +} 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 new file mode 100644 index 00000000..5336bda8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsServerCertificateInfo.kt @@ -0,0 +1,21 @@ +/* + * 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 + +import java.security.cert.X509Certificate + +data class TlsServerCertificateInfo( + val serverUrl: String, + val certificateChain: List, + val fingerprintSha256: String, + val problem: TlsTrustProblem +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustDecision.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustDecision.kt new file mode 100644 index 00000000..775d0bd2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustDecision.kt @@ -0,0 +1,23 @@ +/* + * 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 + +data class TlsTrustDecision( + val trusted: Boolean, + val scope: TlsTrustScope? = null +) { + companion object { + fun reject() = TlsTrustDecision(false) + fun sessionOnly() = TlsTrustDecision(true, TlsTrustScope.SESSION_ONLY) + fun permanent() = TlsTrustDecision(true, TlsTrustScope.PERMANENT) + } +} 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 new file mode 100644 index 00000000..1a13d5bb --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustManager.kt @@ -0,0 +1,26 @@ +/* + * 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 + +interface TlsTrustManager { + + /** + * Ensures the TLS certificate of the given server is trusted. + * + * @throws TlsTrustRejectedException if the user rejects the certificate + * @throws javax.net.ssl.SSLHandshakeException if TLS ultimately fails + */ + suspend fun ensureTrusted( + serverUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + ): TlsContext +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustProblem.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustProblem.kt new file mode 100644 index 00000000..b1942284 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustProblem.kt @@ -0,0 +1,17 @@ +/* + * 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 + +enum class TlsTrustProblem { + UNTRUSTED_CERTIFICATE, + CERTIFICATE_CHANGED +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustRejectedException.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustRejectedException.kt new file mode 100644 index 00000000..f3d96583 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustRejectedException.kt @@ -0,0 +1,15 @@ +/* + * 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 + +class TlsTrustRejectedException : + RuntimeException("TLS certificate was rejected by the user") diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustScope.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustScope.kt new file mode 100644 index 00000000..3e4827d3 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsTrustScope.kt @@ -0,0 +1,17 @@ +/* + * 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 + +enum class TlsTrustScope { + SESSION_ONLY, + PERMANENT +} 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 new file mode 100644 index 00000000..2a1be178 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt @@ -0,0 +1,101 @@ +/* + * 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..0298bd01 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/UiTlsDecisionAdapter.kt @@ -0,0 +1,41 @@ +/* + * 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/KubeConfigEntries.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt index 3a1af7d7..39434c77 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -201,6 +201,17 @@ data class KubeConfigNamedUser( return fromMap(userObject)?.user?.token } + fun getUserClientCertForCluster(clusterName: String, kubeConfig: KubeConfig): Pair? { + val contextEntry = KubeConfigNamedContext.getByName(clusterName, kubeConfig) ?: return null + val userObject = (kubeConfig.users as? List<*>)?.firstOrNull { userObject -> + val userMap = userObject as? Map<*, *> ?: return@firstOrNull false + val userName = userMap["name"] as? String ?: return@firstOrNull false + userName == contextEntry.context.user + } as? Map<*,*> ?: return null + val user = fromMap(userObject)?.user + return Pair(user?.clientCertificateData, user?.clientKeyData) + } + fun isTokenAuth(kubeConfig: KubeConfig): Boolean { return kubeConfig.credentials?.containsKey(KubeConfig.CRED_TOKEN_KEY) == true } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt index 85247811..da681b71 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt @@ -35,6 +35,17 @@ abstract class KubeConfigUpdate private constructor( UpdateToken(clusterName, clusterUrl, token, context, allConfigs) } } + + fun create(clusterName: String, clusterUrl: String, clientCertPem: String, clientKeyPem: String): KubeConfigUpdate { + val allConfigs = KubeConfigUtils.getAllConfigs(KubeConfigUtils.getAllConfigFiles()) + val context = KubeConfigNamedContext.getByClusterName(clusterName, allConfigs) + return if (context == null) { + CreateContextWithClientCert(clusterName, clusterUrl, clientCertPem, clientKeyPem, allConfigs) + } else { + UpdateClientCert(clusterName, clusterUrl, clientCertPem, clientKeyPem, context, allConfigs) + } + } + } abstract fun apply() @@ -195,4 +206,148 @@ abstract class KubeConfigUpdate private constructor( } } + class UpdateClientCert( + clusterName: String, + clusterUrl: String, + private val clientCertPem: String, + private val clientKeyPem: String, + private val context: KubeConfigNamedContext, + allConfigs: List + ) : KubeConfigUpdate(clusterName, clusterUrl, "", allConfigs) { + + override fun apply() { + updateClientCert(context.context.user) + updateCurrentContext(context.name) + } + + private fun updateClientCert(username: String) { + val config = KubeConfigUtils.getConfigByUser(context, allConfigs) ?: return + + config.users?.find { user -> + username == Utils.getValue(user, arrayOf("name")) + }?.apply { + Utils.setValue(this, clientCertPem, arrayOf("user", "client-certificate-data")) + Utils.setValue(this, clientKeyPem, arrayOf("user", "client-key-data")) + + // remove token if present + Utils.removeValue(this, arrayOf("user", "token")) + } + + save( + config.contexts, + config.clusters, + config.users, + config.preferences, + config.currentContext, + config.path + ) + } + + private fun updateCurrentContext(contextName: String) { + val config = KubeConfigUtils.getConfigWithCurrentContext(allConfigs) ?: return + save( + config.contexts, + config.clusters, + config.users, + config.preferences, + contextName, + config.path + ) + } + } + + class CreateContextWithClientCert( + clusterName: String, + clusterUrl: String, + private val clientCertPem: String, + private val clientKeyPem: String, + allConfigs: List + ) : KubeConfigUpdate(clusterName, clusterUrl, "", allConfigs) { + + override fun apply() { + val config = allConfigs.firstOrNull() ?: return + + val user = createUser(allConfigs) + val users = config.users ?: ArrayList() + users.add(user.toMap()) + + val cluster = createCluster(allConfigs) + val clusters = config.clusters ?: ArrayList() + clusters.add(cluster.toMap()) + + val context = createContext(user, cluster, allConfigs) + val contexts = config.contexts ?: ArrayList() + contexts.add(context.toMap()) + + config.setContext(context.name) + + save( + contexts, + clusters, + users, + config.preferences, + config.currentContext, + config.path + ) + } + + private fun createUser(allConfigs: List): KubeConfigNamedUser { + val existingUserNames = getAllExistingNames(allConfigs) { it.users } + val uniqueUserName = + UniqueNameGenerator.generateUniqueName(clusterName, existingUserNames) + + return KubeConfigNamedUser( + KubeConfigUser( + token = null, + clientCertificateData = clientCertPem, + clientKeyData = clientKeyPem + ), + uniqueUserName + ) + } + + private fun createCluster(allConfigs: List): KubeConfigNamedCluster { + val existingClusterNames = getAllExistingNames(allConfigs) { it.clusters } + val uniqueClusterName = + UniqueNameGenerator.generateUniqueName(clusterName, existingClusterNames) + + return KubeConfigNamedCluster( + KubeConfigCluster(clusterUrl), + uniqueClusterName + ) + } + + private fun createContext( + user: KubeConfigNamedUser, + cluster: KubeConfigNamedCluster, + allConfigs: List + ): KubeConfigNamedContext { + val existingContextNames = getAllExistingNames(allConfigs) { it.contexts } + + val tempContext = KubeConfigNamedContext( + KubeConfigContext(user.name, cluster.name) + ) + + val uniqueContextName = + UniqueNameGenerator.generateUniqueName(tempContext.name, existingContextNames) + + return KubeConfigNamedContext( + KubeConfigContext(user.name, cluster.name), + uniqueContextName + ) + } + + private fun getAllExistingNames( + allConfigs: List, + extractList: (KubeConfig) -> List<*>? + ): Set { + return allConfigs + .flatMap { config -> extractList(config) ?: emptyList() } + .mapNotNull { entryObject -> + val entryMap = entryObject as? Map<*, *> ?: return@mapNotNull null + entryMap["name"] as? String + } + .toSet() + } + } } 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 3e15639a..e8e0db40 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -1,3 +1,14 @@ +/* + * 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.kubeconfig import com.intellij.openapi.diagnostic.thisLogger @@ -33,7 +44,10 @@ object KubeConfigUtils { kubeConfig.clusters?.mapNotNull { cluster -> val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null val token = KubeConfigNamedUser.getUserTokenForCluster(namedCluster.name, kubeConfig) - val cluster = toCluster(namedCluster, token) + val clientCert:Pair? = KubeConfigNamedUser.getUserClientCertForCluster(namedCluster.name, kubeConfig) + val clientCertData = clientCert?.first + val clientKeyData = clientCert?.second + val cluster = toCluster(namedCluster, token, clientCertData, clientKeyData) logger.debug("Parsed cluster: ${cluster.name} at ${cluster.url}") cluster } ?: emptyList() @@ -70,11 +84,14 @@ object KubeConfigUtils { } } - private fun toCluster(clusterEntry: KubeConfigNamedCluster, userToken: String?): Cluster { + private fun toCluster(clusterEntry: KubeConfigNamedCluster, userToken: String?, + clientCertData: String?, clientKeyData: String? ): Cluster { return Cluster( url = clusterEntry.cluster.server, name = clusterEntry.name, - token = userToken + token = userToken, + clientCertData = clientCertData, + clientKeyData = clientKeyData ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt index 013bb6bd..e6cf7205 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -17,8 +17,20 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toUriWithHost data class Cluster( val name: String, val url: String, - val token: String? = null + val certificateAuthorityData: String? = null, + val token: String? = null, + val clientCertData: String? = null, + val clientKeyData: String? = null ) { + init { + require(!(token != null && clientCertData != null)) { + "Cluster cannot have both token and client certificate authentication" + } + + require((clientCertData == null) == (clientKeyData == null)) { + "Client certificate and key must both be provided or both be null" + } + } companion object { fun fromNameAndUrl(nameAndUrl: String): Cluster? { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt index 34842116..28f07b71 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * 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/ @@ -12,17 +12,29 @@ package com.redhat.devtools.gateway.openshift import com.intellij.openapi.diagnostic.thisLogger +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.security.KeyStore +import java.security.SecureRandom +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import java.io.ByteArrayInputStream +import java.security.cert.CertificateFactory +import java.util.Base64 +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 fun create(): ApiClient { @@ -34,52 +46,177 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { } 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.") + val allConfigs = configUtils.getAllConfigs(paths) + if (allConfigs.isEmpty()) { + thisLogger().debug("No valid kubeconfig content found. Falling back to default ApiClient.") lastUsedKubeConfig = null - ClientBuilder.defaultClient() + 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: CharArray): ApiClient { - val kubeConfig = createKubeConfig(server, token) + fun create( + server: String, + certificateAuthorityData: CharArray? = null, + token: CharArray? = null, + clientCertData: CharArray? = null, + clientKeyData: CharArray? = null, + tlsContext: TlsContext + ): ApiClient { + + val usingToken = token != null && token.isNotEmpty() + val usingClientCert = clientCertData != null && clientCertData.isNotEmpty() + && clientKeyData != null && clientKeyData.isNotEmpty() + val usingCertificateAuthorityData = certificateAuthorityData != null && certificateAuthorityData.isNotEmpty() + + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCertData + clientKeyData." + } + + val kubeConfig = createKubeConfig(server, certificateAuthorityData, token, clientCertData, clientKeyData) lastUsedKubeConfig = kubeConfig - return Config.fromConfig(kubeConfig) + + val client = Config.fromConfig(kubeConfig) + + val trustManager: X509TrustManager = + if (usingCertificateAuthorityData) { + buildTrustManagerFromCaData(certificateAuthorityData) + } else { + tlsContext.trustManager + } + + val keyManagers: Array? = + if (usingClientCert) { + buildKeyManagers(clientCertData, clientKeyData) + } else { + null + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init( + keyManagers, + arrayOf(trustManager), + SecureRandom() + ) + + client.httpClient = client.httpClient.newBuilder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + + return client } - - fun isTokenAuth(): Boolean { - return lastUsedKubeConfig?.let { - KubeConfigUtils.isCurrentUserTokenAuth(it) - } ?: false + + private fun buildTrustManagerFromCaData( + caData: CharArray + ): X509TrustManager { + + val decoded = Base64.getDecoder().decode(String(caData)) + + val certificateFactory = CertificateFactory.getInstance("X.509") + val caCert = certificateFactory.generateCertificate( + ByteArrayInputStream(decoded) + ) + + 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 createKubeConfig(server: String, token: CharArray): KubeConfig { - val cluster = mapOf( + private fun buildKeyManagers( + certData: CharArray, + keyData: CharArray + ): Array { + + val certBytes = Base64.getDecoder().decode(String(certData)) + val keyBytes = Base64.getDecoder().decode(String(keyData)) + + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificate = certificateFactory.generateCertificate( + ByteArrayInputStream(certBytes) + ) + + val privateKey = PemUtils.parsePrivateKey(String(keyBytes)) + + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null) + + keyStore.setKeyEntry( + "client", + privateKey, + CharArray(0), + arrayOf(certificate) + ) + + val kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ) + kmf.init(keyStore, CharArray(0)) + + return kmf.keyManagers + } + + private fun createKubeConfig(server: String, certificateAuthorityData: CharArray? = null, token: CharArray? = null, + clientCertData: CharArray? = null, clientKeyData: CharArray? = null + ): KubeConfig { + + val usingToken = token != null + val usingClientCert = clientCertData != null && clientKeyData != null + + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCertData + clientKeyData." + } + + val cluster = mutableMapOf( + "server" to server.trim() + ) + + val caData = certificateAuthorityData + ?.let { String(it).trim() } + ?.takeIf { it.isNotEmpty() } + + if (caData != null) { + cluster["certificate-authority-data"] = caData + } + + val clusterEntry = mapOf( "name" to clusterName, - "cluster" to mapOf( - "server" to server.trim(), - "insecure-skip-tls-verify" to true - ) + "cluster" to cluster ) - val user = mapOf( + val userAuth = mutableMapOf() + + if (usingToken) { + userAuth["token"] = String(token).trim() + } else { + userAuth["client-certificate-data"] = + String(clientCertData!!).trim() + userAuth["client-key-data"] = + String(clientKeyData!!).trim() + } + + val userEntry = mapOf( "name" to userName, - "user" to mapOf( - "token" to String(token).trim() - ) + "user" to userAuth ) - val context = mapOf( + val contextEntry = mapOf( "name" to contextName, "context" to mapOf( "cluster" to clusterName, @@ -87,7 +224,7 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { ) ) - val kubeConfig = KubeConfig(arrayListOf(context), arrayListOf(cluster), arrayListOf(user)) + val kubeConfig = KubeConfig(arrayListOf(contextEntry), arrayListOf(clusterEntry), arrayListOf(userEntry)) kubeConfig.setContext(contextName) return kubeConfig diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt index 57c4fabb..88e06141 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Utils.kt @@ -27,6 +27,7 @@ object Utils { } @JvmStatic + @Suppress("UNCHECKED_CAST") fun setValue(obj: Any?, value: Any, path: Array) { if (obj !is MutableMap<*, *>) { return @@ -48,6 +49,26 @@ object Utils { } } + @JvmStatic + @Suppress("UNCHECKED_CAST") + fun removeValue(obj: Any?, path: Array) { + if (obj !is MutableMap<*, *>) { + return + } + + var currentMap: MutableMap = obj as MutableMap + + for (i in path.indices) { + val key = path[i] + + if (i == path.lastIndex) { + currentMap.remove(key) + } else { + val nextMap = currentMap[key] as? MutableMap ?: return + currentMap = nextMap + } + } + } } fun mapOfNotNull(vararg pairs: Pair): Map { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt new file mode 100644 index 00000000..964e7078 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/SelectClusterDialog.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view + +import com.intellij.openapi.ui.DialogWrapper +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.view.steps.DevSpacesServerStepView +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.JComponent +import javax.swing.JPanel + +class SelectClusterDialog( + ctx: DevSpacesContext +) : DialogWrapper(null, true) { + + private val stepView: DevSpacesServerStepView + + init { + title = DevSpacesBundle.message("connector.dialog.select_cluster.title") + + stepView = DevSpacesServerStepView( + devSpacesContext = ctx, + enableNextButton = { isOKActionEnabled = true }, + triggerNextAction = { doOKAction() } + ) + + isOKActionEnabled = false + init() + stepView.onInit() + } + + override fun createCenterPanel(): JComponent { + val panel = JPanel(BorderLayout()) + panel.add(stepView.component, BorderLayout.CENTER) + return panel + } + + override fun getInitialSize(): Dimension { + return Dimension(750, 520) + } + + override fun doOKAction() { + if (stepView.onNext()) { + super.doOKAction() + } + } + + override fun doCancelAction() { + stepView.onDispose() + super.doCancelAction() + } + + fun showAndConnect(): Boolean { + return showAndGet() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index cf745f2b..dcbfe699 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * 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/ @@ -13,24 +13,34 @@ package com.redhat.devtools.gateway.view.steps import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.invokeLater +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.ProgressIndicator import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext -import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider import com.redhat.devtools.gateway.auth.code.AuthTokenKind import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager 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.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate @@ -43,16 +53,19 @@ import com.redhat.devtools.gateway.view.ui.Dialogs 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 io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.* import java.awt.event.ItemEvent import java.awt.event.KeyAdapter import java.awt.event.KeyEvent +import java.nio.file.Paths +import javax.swing.JComponent import javax.swing.JTextField import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener -import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS -import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager -import io.kubernetes.client.openapi.ApiClient + +private const val SAVE_REQUIRES_TOKEN_DIFF = + "devspaces.save.requires.token.diff" class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, @@ -67,18 +80,87 @@ class DevSpacesServerStepView( private lateinit var kubeconfigScope: CoroutineScope private lateinit var kubeconfigMonitor: KubeConfigMonitor - private val updateKubeconfigCheckbox = JBCheckBox("Save configuration") + private var saveToKubeconfig: Boolean = false + private val saveKubeconfigCheckboxes = mutableListOf() + + private fun syncSaveKubeconfigCheckboxes(source: JBCheckBox) { + saveKubeconfigCheckboxes + .filter { it !== source } + .forEach { it.isSelected = saveToKubeconfig } + } + + private fun createSaveKubeconfigCheckbox( + requiresTokenDiff: Boolean? = false + ): JBCheckBox = + JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")).apply { + isOpaque = false + background = null + + isSelected = saveToKubeconfig + + putClientProperty(SAVE_REQUIRES_TOKEN_DIFF, requiresTokenDiff) + + addActionListener { + saveToKubeconfig = isSelected + syncSaveKubeconfigCheckboxes(this) + } + + saveKubeconfigCheckboxes += this + } + + private val updateKubeconfigCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")) + private val sessionManager = ApplicationManager.getApplication() .getService(RedHatAuthSessionManager::class.java) - private var tfToken = JBTextField() + private var tfToken = JBPasswordField() + .apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + private var tfCertAuthorityData = JBTextField() + .apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + + private val tfUsername = JBTextField() .apply { - document.addDocumentListener(onTokenChanged()) + document.addDocumentListener(onFieldChanged()) PasteClipboardMenu.addTo(this) addKeyListener(createEnterKeyListener()) } + private val tfPassword = JBPasswordField() + .apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + private val tfClientCert = JBTextField() + .apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + private val tfClientKey = JBTextField() + .apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + private val showTokenCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token")) + .apply { + isOpaque = false + background = null + } + private val showPasswordCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_password")) + .apply { + isOpaque = false + background = null + } + private var tfServer = FilteringComboBox.create( { it?.toString() ?: "" }, @@ -90,72 +172,155 @@ class DevSpacesServerStepView( (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) } - private enum class AuthMethod { - TOKEN, - OPENSHIFT, - SSO + private enum class AuthMethod { + TOKEN, // User token + CLIENT_CERTIFICATE, // Client certificate + OPENSHIFT, // browser PKCE + OPENSHIFT_CREDENTIALS, // username/password + REDHAT_SSO // RH SSO (Sandbox) } private var authMethod: AuthMethod = AuthMethod.TOKEN + private fun updateAuthUiState() { - tfToken.isEnabled = authMethod == AuthMethod.TOKEN enableNextButton?.invoke() } - override val component = panel { + private fun getCurrentAuthTokenValue(): CharArray? = + when (authMethod) { + AuthMethod.TOKEN -> tfToken.password + else -> null // other tabs don't have a token yet + } + + private fun tokenPanel() = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { + cell(tfToken).align(Align.FILL) + } row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { - font = JBFont.h2().asBold() - } + cell(showTokenCheckbox) } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.server")) { - cell(tfServer).align(Align.FILL) + row { + cell(createSaveKubeconfigCheckbox(true).also { saveKubeconfigCheckboxes += it }) } + } - buttonsGroup { - row("Authentication") { - radioButton("Token") - .applyToComponent { - isSelected = true - toolTipText = "Use a manually provided token from kubeconfig or oc login" - addActionListener { - authMethod = AuthMethod.TOKEN - updateAuthUiState() - } - } + private fun clientCertificatePanel() = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_certificate")) { + cell(tfClientCert).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_key")) { + cell(tfClientKey).align(Align.FILL) + } + row { + cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) + } + } - radioButton("OpenShift OAuth") - .applyToComponent { - addActionListener { - toolTipText = "Authenticate via OpenShift Authenticator (oc login --web)" - authMethod = AuthMethod.OPENSHIFT - updateAuthUiState() - } - } + private fun openShiftOAuthPanel() = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.openshift_oauth_info")) + } + row { + cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) + } + } - radioButton("Red Hat SSO (Sandbox)") - .applyToComponent { - addActionListener { - toolTipText = "Authenticate via Red Hat SSO token (Sandbox only)" - authMethod = AuthMethod.SSO - updateAuthUiState() - } - } + private fun credentialsPanel() = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.username")) { + cell(tfUsername).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.password")) { + cell(tfPassword).align(Align.FILL) + } + row { + cell(showPasswordCheckbox) + } + row { + cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) + } + } + + private fun redHatSSOPanel() = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_info")) + } + row { + label( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_token_note") + ).comment( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.pipeline_token_comment") + ) + } + } + + private fun tabPanel(p: JComponent): JComponent = + p.apply { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + } + + private val authTabs = JBTabbedPane().apply { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.token"), + tabPanel(tokenPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.client_certificate"), + tabPanel(clientCertificatePanel()) + ) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.openshift_oauth"), + tabPanel(openShiftOAuthPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.credentials"), + tabPanel(credentialsPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.redhat_sso"), + tabPanel(redHatSSOPanel())) + + addChangeListener { + authMethod = when (selectedIndex) { + 0 -> AuthMethod.TOKEN + 1 -> AuthMethod.CLIENT_CERTIFICATE + 2 -> AuthMethod.OPENSHIFT + 3 -> AuthMethod.OPENSHIFT_CREDENTIALS + else -> AuthMethod.REDHAT_SSO } + + updateKubeconfigCheckbox.isVisible = + authMethod != AuthMethod.REDHAT_SSO + + enableNextButton?.invoke() } + } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { - cell(tfToken).align(Align.FILL) + val bodyPanel = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.server")) { + cell(tfServer).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority")) { + cell(tfCertAuthorityData).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) { + cell(authTabs).align(Align.FILL) } + }.apply { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + } - row("") { - cell(updateKubeconfigCheckbox).applyToComponent { - isOpaque = false - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + override val component = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { + font = JBFont.h2().asBold() } - enabled(false) } + row { + cell(bodyPanel).align(AlignX.FILL).align(AlignY.FILL) + }.resizableRow() }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() border = JBUI.Borders.empty(8) @@ -169,45 +334,74 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() updateAuthUiState() + + showTokenCheckbox.addActionListener { + tfToken.echoChar = if (showTokenCheckbox.isSelected) 0.toChar() else '•' + } + + showPasswordCheckbox.addActionListener { + tfPassword.echoChar = if (showPasswordCheckbox.isSelected) 0.toChar() else '•' + } + } + + override fun onDispose() { + stopKubeconfigMonitor() + super.onDispose() } private fun onClusterSelected(event: ItemEvent) { if (event.stateChange == ItemEvent.SELECTED) { (event.item as? Cluster)?.let { selectedCluster -> if (allClusters.contains(selectedCluster)) { + tfCertAuthorityData.text = selectedCluster.certificateAuthorityData tfToken.text = selectedCluster.token + tfClientCert.text = selectedCluster.clientCertData + tfClientKey.text = selectedCluster.clientKeyData updateKubeconfigCheckbox.isSelected = false } } } - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } - private fun onTokenChanged(): DocumentListener = object : DocumentListener { + private fun onFieldChanged(): DocumentListener = object : DocumentListener { override fun insertUpdate(event: DocumentEvent) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } override fun removeUpdate(e: DocumentEvent) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } override fun changedUpdate(e: DocumentEvent?) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } } - private fun enableKubeconfigCheckbox() { - val cluster = tfServer.selectedItem as Cluster? - val token = tfToken.text - updateKubeconfigCheckbox.isEnabled = - !allClusters.contains(cluster) - || (cluster?.token ?: "") != token + private fun updateSaveKubeconfigCheckboxEnablement() { + val cluster = tfServer.selectedItem as? Cluster + val currentToken = getCurrentAuthTokenValue() + + val tokenChanged = + !cluster?.token.isNullOrBlank() + && currentToken?.isNotEmpty() == true + && !cluster.token.toCharArray().contentEquals(currentToken) + + saveKubeconfigCheckboxes.forEach { checkbox -> + val requiresTokenDiff = + checkbox.getClientProperty(SAVE_REQUIRES_TOKEN_DIFF) as? Boolean ?: false + + checkbox.isEnabled = + !allClusters.contains(cluster) + || !requiresTokenDiff + || tokenChanged + } } + private fun createEnterKeyListener(): KeyAdapter { return object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { @@ -221,21 +415,24 @@ class DevSpacesServerStepView( private fun onClustersChanged(): suspend (List) -> Unit = { updatedClusters -> this.allClusters = updatedClusters if (updatedClusters.isNotEmpty()) { - invokeLater { - val kubeConfigCurrentCluster = KubeConfigUtils.getCurrentClusterName() - val previouslySelected = tfServer.selectedItem as? Cluster? - setClusters(updatedClusters) - setSelectedCluster( - (previouslySelected)?.name ?: kubeConfigCurrentCluster, - updatedClusters - ) - enableKubeconfigCheckbox() - } + ApplicationManager.getApplication().invokeLater( + { + val kubeConfigCurrentCluster = KubeConfigUtils.getCurrentClusterName() + val previouslySelected = tfServer.selectedItem as? Cluster? + setClusters(updatedClusters) + setSelectedCluster( + (previouslySelected)?.name ?: kubeConfigCurrentCluster, + updatedClusters + ) + updateSaveKubeconfigCheckboxEnablement() + }, + ModalityState.stateForComponent(component) + ) } } override fun onPrevious(): Boolean { - stopKubeconfigMonitor() + onDispose() return true } @@ -244,7 +441,9 @@ class DevSpacesServerStepView( val server = selectedCluster.url var success = false - stopKubeconfigMonitor() + if (!confirmAuthSwitchIfNeeded()) return false + + onDispose() ProgressManager.getInstance().runProcessWithProgressSynchronously( { @@ -253,25 +452,87 @@ class DevSpacesServerStepView( try { indicator.text = "Connecting to cluster..." + val tlsContext = runBlocking { + resolveSslContext(server) + } + + val certAuthorityData = tfCertAuthorityData.text + when (authMethod) { AuthMethod.TOKEN -> { indicator.text = "Validating token..." - val token = tfToken.text + val token = String(tfToken.password) - val client = createValidatedApiClient(server, token, + val client = createValidatedApiClient(server, certAuthorityData, + token, null, null, tlsContext, "Authentication failed: invalid server URL or token.") saveKubeconfig(selectedCluster, token, indicator) devSpacesContext.client = client } + AuthMethod.CLIENT_CERTIFICATE -> { + indicator.text = "Validating client certificate..." + + val clientCertPem = tfClientCert.text + val clientKeyPem = tfClientKey.text + + val client = createValidatedApiClient(server, certAuthorityData, + null, clientCertPem, clientKeyPem, tlsContext, + "Authentication failed: invalid server URL or token.") + + require(Projects(client).isAuthenticated()) { + "Authentication failed: invalid client certificate or key." + } + + saveKubeconfig(selectedCluster, clientCertPem, clientKeyPem, indicator) + devSpacesContext.client = client + } + + AuthMethod.OPENSHIFT_CREDENTIALS -> { + indicator.text = "Authenticating with OpenShift credentials..." + + val username = tfUsername.text + val password = String(tfPassword.password) + + val finalToken = runBlocking { + val sessionManager = OpenShiftAuthSessionManager() + + val osToken = sessionManager.loginWithCredentials( + apiServerUrl = selectedCluster.url, + username = username, + password = password, + tlsContext.sslContext + ) + + TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient(server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: invalid OpenShift credentials." + ) + + tfToken.text = finalToken.accessToken + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + AuthMethod.OPENSHIFT -> { indicator.text = "Authenticating with Openshift..." val finalToken = runBlocking { val openshiftSSessionManager = OpenShiftAuthSessionManager() - val uri = openshiftSSessionManager.startLogin(selectedCluster.url) + val uri = openshiftSSessionManager.startLogin(selectedCluster.url, tlsContext.sslContext) BrowserUtil.browse(uri) indicator.text = "Waiting for you to complete login in your browser..." @@ -291,7 +552,8 @@ class DevSpacesServerStepView( indicator.text = "Validating cluster access..." - val client = createValidatedApiClient(server, finalToken.accessToken, + val client = createValidatedApiClient(server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, "Authentication failed: token received from OpenShift Authenticator is invalid or expired.") tfToken.text = finalToken.accessToken @@ -299,11 +561,11 @@ class DevSpacesServerStepView( devSpacesContext.client = client } - AuthMethod.SSO -> { + AuthMethod.REDHAT_SSO -> { indicator.text = "Authenticating with Red Hat..." val finalToken = runBlocking { - val uri = sessionManager.startLogin() + val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) BrowserUtil.browse(uri) indicator.text = "Waiting for you to complete login in your browser..." @@ -318,7 +580,8 @@ class DevSpacesServerStepView( indicator.text = "Validating cluster access..." - val client = createValidatedApiClient(server, finalToken.accessToken, + val client = createValidatedApiClient(server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster.") // Do not save SSO tokens @@ -350,45 +613,134 @@ class DevSpacesServerStepView( return success } + private fun confirmAuthSwitchIfNeeded(): Boolean { + val tokenPresent = tfToken.password.isNotEmpty() + val certPresent = tfClientCert.text.isNotBlank() || tfClientKey.text.isNotBlank() + + val (message, shouldAsk) = when (authMethod) { + AuthMethod.TOKEN -> { + if (certPresent) { + "Switching to token authentication will remove the configured client certificate. Continue?" to true + } else null to false + } + + AuthMethod.CLIENT_CERTIFICATE -> { + if (tokenPresent) { + "Switching to client certificate authentication will remove the configured token. Continue?" to true + } else null to false + } + + else -> null to false + } + + if (!shouldAsk || message == null) return true + + return MessageDialogBuilder + .yesNo( + "Change Authentication Method", + message + ) + .yesText("Switch") + .noText("Cancel") + .ask(component) + } + @Throws(IllegalArgumentException::class) private fun createValidatedApiClient( server: String, - token: String, + certificateAuthorityData: String? = null, + token: String? = null, + clientCertPem: String? = null, + clientKeyPem: String? = null, + tlsContext: TlsContext, errorMessage: String? = null ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) - .create(server, token.toCharArray()) + .create(server, certificateAuthorityData?.toCharArray(), token?.toCharArray(), + clientCertPem?.toCharArray(), clientKeyPem?.toCharArray(), tlsContext) .also { client -> require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } } - override fun isNextEnabled(): Boolean { - if (tfServer.selectedItem == null) return false + override fun isNextEnabled(): Boolean = + when (authMethod) { + AuthMethod.TOKEN -> + tfToken.password?.isNotEmpty() == true + + AuthMethod.CLIENT_CERTIFICATE -> + tfClientCert.text.isNotBlank() && tfClientKey.text.isNotBlank() - return when (authMethod) { - AuthMethod.TOKEN -> tfToken.text.isNotBlank() - AuthMethod.OPENSHIFT, AuthMethod.SSO -> true + AuthMethod.OPENSHIFT_CREDENTIALS -> + tfUsername.text.isNotBlank() && + tfPassword.password?.isNotEmpty() == true + + AuthMethod.OPENSHIFT, + AuthMethod.REDHAT_SSO -> + tfServer.selectedItem != null } + + private val sessionTrustStore = SessionTlsTrustStore() + private val persistentKeyStore = PersistentKeyStore( + path = Paths.get( + PathManager.getConfigPath(), + "devspaces", + "tls-truststore.p12" + ), + password = CharArray(0) + ) + + private val tlsTrustManager = DefaultTlsTrustManager( + kubeConfigProvider = { + KubeConfigUtils.getAllConfigs( + KubeConfigUtils.getAllConfigFiles() + ) + }, + kubeConfigWriter = { namedCluster, certs -> + KubeConfigTlsWriter.write(namedCluster, certs) + }, + sessionTrustStore = sessionTrustStore, + persistentKeyStore = persistentKeyStore + ) + + private suspend fun resolveSslContext(serverUrl: String): TlsContext { + return tlsTrustManager.ensureTrusted( + serverUrl = serverUrl, + decisionHandler = UiTlsDecisionAdapter::decide + ) } private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { - if (cluster == null - || token.isNullOrBlank() - || !updateKubeconfigCheckbox.isSelected) { - return - } + if (!saveToKubeconfig || cluster == null || token.isNullOrBlank()) return + + try { + indicator.text = "Updating Kube config..." + KubeConfigUpdate + .create( + cluster.name.trim(), + cluster.url.trim(), + token.trim()) + .apply() + } catch (e: Exception) { + thisLogger().warn(e.message ?: "Could not save configuration file", e) + Dialogs.error( e.message ?: "Could not save configuration file", "Save Config Failed") + } + } - try { - indicator.text = "Updating Kube config..." - KubeConfigUpdate - .create( - cluster.name.trim(), - cluster.url.trim(), - token.trim()) - .apply() - } catch (e: Exception) { - thisLogger().warn(e.message ?: "Could not save configuration file", e) - Dialogs.error( e.message ?: "Could not save configuration file", "Save Config Failed") - } + private fun saveKubeconfig(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, indicator: ProgressIndicator) { + if (!saveToKubeconfig || cluster == null || clientCertPem.isNullOrBlank() || clientKeyPem.isNullOrBlank()) return + + try { + indicator.text = "Updating Kube config..." + KubeConfigUpdate + .create( + cluster.name.trim(), + cluster.url.trim(), + clientCertPem.trim(), + clientKeyPem.trim()) + .apply() + } catch (e: Exception) { + thisLogger().warn(e.message ?: "Could not save configuration file", e) + Dialogs.error( e.message ?: "Could not save configuration file", "Save Config Failed") + } } private fun setClusters(clusters: List) { @@ -405,7 +757,10 @@ class DevSpacesServerStepView( ?: clusters.firstOrNull { it.id == saved?.id } ?: clusters.firstOrNull() tfServer.selectedItem = toSelect + tfCertAuthorityData.text = toSelect?.certificateAuthorityData ?: "" tfToken.text = toSelect?.token ?: "" + tfClientCert.text = toSelect?.clientCertData ?: "" + tfClientKey.text = toSelect?.clientKeyData ?: "" } private fun startKubeconfigMonitor() { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt index 93f57f8a..2e4859a0 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWizardStep.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Red Hat, Inc. + * 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/ @@ -29,4 +29,6 @@ sealed interface DevSpacesWizardStep { * Default implementation returns true. */ fun isNextEnabled(): Boolean = true + + fun onDispose() {} } \ No newline at end of file diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index fddbaff1..ab461344 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -5,10 +5,31 @@ connector.title=Dev Spaces # Wizard OpenShift connection step connector.wizard_step.openshift_connection.title=Connecting to OpenShift API server connector.wizard_step.openshift_connection.label.server=Server: +connector.wizard_step.openshift_connection.label.authentication=Authentication: +connector.wizard_step.openshift_connection.tab.token=Token +connector.wizard_step.openshift_connection.tab.client_certificate=Client Certificate +connector.wizard_step.openshift_connection.tab.openshift_oauth=OpenShift OAuth +connector.wizard_step.openshift_connection.tab.credentials=Username / Password +connector.wizard_step.openshift_connection.tab.redhat_sso=Red Hat SSO connector.wizard_step.openshift_connection.label.token=Token: +connector.wizard_step.openshift_connection.label.certificate_authority=Certificate Authority (PEM): +connector.wizard_step.openshift_connection.label.client_certificate=Client Certificate (PEM): +connector.wizard_step.openshift_connection.label.client_key=Client Key (PEM): +connector.wizard_step.openshift_connection.label.username=Username: +connector.wizard_step.openshift_connection.label.password=Password: +connector.wizard_step.openshift_connection.checkbox.save_configuration=Save configuration +connector.wizard_step.openshift_connection.checkbox.show_token=Show token +connector.wizard_step.openshift_connection.checkbox.show_password=Show password +connector.wizard_step.openshift_connection.text.openshift_oauth_info=Authenticate using OpenShift OAuth (browser login) +connector.wizard_step.openshift_connection.text.redhat_sso_info=Authenticate using Red Hat SSO (Sandbox only) +connector.wizard_step.openshift_connection.text.redhat_sso_token_note=Token will not be saved to kubeconfig +connector.wizard_step.openshift_connection.text.pipeline_token_comment=Pipeline tokens require special handling connector.wizard_step.openshift_connection.button.previous=Back connector.wizard_step.openshift_connection.button.next=Check connection +# Connection Provider's Select Cluster dialog +connector.dialog.select_cluster.title=Connect to Kubernetes Cluster + # Wizard selecting DevWorkspace step connector.wizard_step.remote_server_connection.title=Select running DevWorkspace connector.wizard_step.remote_server_connection.button.previous=Back diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt index 62eff46c..2edd861e 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -65,7 +65,7 @@ class KubeConfigMonitorTest { @Test fun `#start should initially parse and publish clusters`() = testScope.runTest { // given - val cluster1 = Cluster("skywalker", "url1", null) + val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) @@ -82,8 +82,8 @@ class KubeConfigMonitorTest { @Test fun `#onFileChanged should reparse and publish updated clusters`() = testScope.runTest { // given - val cluster1 = Cluster("skywalker", "url1", null) - val cluster1Updated = Cluster("skywalker", "url1", "token1") + val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) + val cluster1Updated = Cluster(name = "skywalker", url = "url1", token = "token1") every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) @@ -105,8 +105,8 @@ class KubeConfigMonitorTest { @Test fun `#updateMonitoredPaths should add and remove files based on KUBECONFIG env var`() = testScope.runTest { // given - val cluster1 = Cluster("skywalker", "url1") - val cluster2 = Cluster("obi-wan", "url2") + val cluster1 = Cluster(name = "skywalker", url = "url1") + val cluster2 = Cluster(name = "obi-wan", url = "url2") // Initial KUBECONFIG every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt index 324c614e..5923ccec 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt @@ -24,7 +24,7 @@ class ClusterTest { val token = "empire-token-4ls" // when - val cluster = Cluster(name, url, token) + val cluster = Cluster(name = name, url = url, token = token) // then assertThat(cluster.name) @@ -44,7 +44,7 @@ class ClusterTest { val url = "https://api.tatooine.galaxy" // when - val cluster = Cluster(name, url) + val cluster = Cluster(name = name, url = url) // then assertThat(cluster.name) @@ -73,7 +73,7 @@ class ClusterTest { @Test fun `#id property returns formatted id removing protocol`() { - val cluster1 = Cluster("x-wing", "https://api.xwing.rebel") + val cluster1 = Cluster(name = "x-wing", url = "https://api.xwing.rebel") assertThat(cluster1.id) .isEqualTo("x-wing@api.xwing.rebel") @@ -93,9 +93,9 @@ class ClusterTest { @Test fun `#equals returns true for clusters with same properties`() { // given - val cluster1 = Cluster("r2d2", "https://api.robots.galaxy", "droid-token-1") - val cluster2 = Cluster("r2d2", "https://api.robots.galaxy", "droid-token-1") - val cluster3 = Cluster("c3po", "https://api.robots.galaxy", "droid-token-1") + val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster3 = Cluster(name = "c3po", url = "https://api.robots.galaxy", token = "droid-token-1") // when & then assertThat(cluster1) @@ -107,8 +107,8 @@ class ClusterTest { @Test fun `#hashCode returns same value for clusters with same properties`() { // given - val cluster1 = Cluster("r2d2", "https://api.robots.galaxy", "droid-token-1") - val cluster2 = Cluster("r2d2", "https://api.robots.galaxy", "droid-token-1") + val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") // when & then assertThat(cluster1.hashCode()) @@ -118,7 +118,7 @@ class ClusterTest { @Test fun `#copy method creates new instance with modified properties`() { // given - val original = Cluster("obi-wan", "https://api.kenobi.jedi", "kenobi-token-1") + val original = Cluster(name = "obi-wan", url = "https://api.kenobi.jedi", token = "kenobi-token-1") // when val copy = original.copy(name = "ben-kenobi") @@ -153,7 +153,7 @@ class ClusterTest { @Test fun `#id returns name@url without scheme, port nor path`() { - val cluster1 = Cluster("x-wing", "https://api.xwing.rebel") + val cluster1 = Cluster(name = "x-wing", url = "https://api.xwing.rebel") assertThat(cluster1.id) .isEqualTo("x-wing@api.xwing.rebel") @@ -221,4 +221,103 @@ class ClusterTest { assertThat(cluster?.url) .isEqualTo(url) } + + @Test + fun `#Cluster constructor creates instance with client certificate authentication`() { + // given + val name = "yavin" + val url = "https://api.yavin.rebel" + val cert = "cert-data" + val key = "key-data" + + // when + val cluster = Cluster( + name = name, + url = url, + clientCertData = cert, + clientKeyData = key + ) + + // then + assertThat(cluster.name).isEqualTo(name) + assertThat(cluster.url).isEqualTo(url) + assertThat(cluster.token).isNull() + assertThat(cluster.clientCertData).isEqualTo(cert) + assertThat(cluster.clientKeyData).isEqualTo(key) + } + + @Test + fun `#Cluster constructor allows token-only authentication`() { + val cluster = Cluster( + name = "scarif", + url = "https://api.scarif.empire", + token = "empire-token" + ) + + assertThat(cluster.token).isEqualTo("empire-token") + assertThat(cluster.clientCertData).isNull() + assertThat(cluster.clientKeyData).isNull() + } + + @Test + fun `#Cluster constructor fails when both token and client certificate are provided`() { + assertThatThrownBy { + Cluster( + name = "mustafar", + url = "https://api.mustafar.sith", + token = "vader-token", + clientCertData = "cert", + clientKeyData = "key" + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#Cluster constructor fails when certificate is provided without key`() { + assertThatThrownBy { + Cluster( + name = "kamino", + url = "https://api.kamino.cloners", + clientCertData = "cert-only" + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#Cluster constructor fails when key is provided without certificate`() { + assertThatThrownBy { + Cluster( + name = "geonosis", + url = "https://api.geonosis.droids", + clientKeyData = "key-only" + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#equals and hashCode include client certificate fields`() { + val cluster1 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + clientCertData = "cert", + clientKeyData = "key" + ) + + val cluster2 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + clientCertData = "cert", + clientKeyData = "key" + ) + + val cluster3 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + token = "ewok-token" + ) + + assertThat(cluster1).isEqualTo(cluster2) + assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) + assertThat(cluster1).isNotEqualTo(cluster3) + } } From 345059b09c1dbd7b24b9e324f8fa545aa670544d Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Fri, 6 Mar 2026 01:14:06 +0100 Subject: [PATCH 03/32] feat: Gateway: CRW-8927 - Simplify login in to the OCP cluster from the Gateway plugin This PR adds a watcher for the Clipboard that allows copy-pasting "sha256~"-like tokens to the Token field of the Select Cluster [via Token] page of the DevSpace connection wizard Signed-off-by: Victor Rubezhny Assisted-by: OpenAI ChatGPT --- .../view/steps/DevSpacesServerStepView.kt | 120 ++++++++++++++++-- 1 file changed, 112 insertions(+), 8 deletions(-) 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 dcbfe699..140c67fb 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 @@ -21,10 +21,8 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBPasswordField -import com.intellij.ui.components.JBTabbedPane -import com.intellij.ui.components.JBTextField +import com.intellij.ui.JBColor +import com.intellij.ui.components.* import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.AlignY @@ -55,9 +53,11 @@ import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu import com.redhat.devtools.gateway.view.ui.requestInitialFocus import io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.* -import java.awt.event.ItemEvent -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent +import java.awt.Cursor +import java.awt.Font +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.event.* import java.nio.file.Paths import javax.swing.JComponent import javax.swing.JTextField @@ -110,11 +110,105 @@ class DevSpacesServerStepView( private val updateKubeconfigCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")) - private val sessionManager = ApplicationManager.getApplication() .getService(RedHatAuthSessionManager::class.java) + private var lastClipboardValue: String? = null + private var clipboardPollingJob: Job? = null + + fun startClipboardPolling() { + clipboardPollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + val value = readClipboardText() + + if (value != null && value != lastClipboardValue) { + lastClipboardValue = value + + suggestToken(value) + } + + delay(500) + } + } + } + + fun readClipboardText(): String? { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val contents = clipboard.getContents(null) ?: return null + + return try { + if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { + contents.getTransferData(DataFlavor.stringFlavor) as? String + } else { + null + } + } catch (_: Exception) { + null + }?.trim() + } + + fun stopClipboardPolling() { + clipboardPollingJob?.cancel() + clipboardPollingJob = null + } + + private val OPENSHIFT_TOKEN_REGEX = + Regex("^sha256~[A-Za-z0-9_-]{20,}$") + + fun String?.isOpenShiftToken(): Boolean = + this?.let { OPENSHIFT_TOKEN_REGEX.matches(it.trim()) } == true + + private fun checkClipboardForToken() { + val token = readClipboardText() + if (token.isOpenShiftToken()) { + suggestToken(token) + } + } + + private fun suggestToken(token: String?) { + ApplicationManager.getApplication().invokeLater ( + { + if (token.isOpenShiftToken() == true) { + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true + } + } else { + tokenSuggestionLabel.apply { + isVisible = false + isEnabled = false + } + } + }, + ModalityState.stateForComponent(component) + ) + } + + private val tokenSuggestionLabel = JBLabel() + .apply { + text = "" + foreground = JBColor.BLUE + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = false + font = font.deriveFont(Font.ITALIC or Font.PLAIN) + } + + private var tokenLabelListener: MouseAdapter? = null + + private fun setupTokenSuggestionLabel() { + tokenLabelListener = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + val token = lastClipboardValue ?: return + tfToken.text = token + tokenSuggestionLabel.isVisible = false + } + } + tokenSuggestionLabel.addMouseListener(tokenLabelListener) + tokenSuggestionLabel.isVisible = false + } + private var tfToken = JBPasswordField() .apply { document.addDocumentListener(onFieldChanged()) @@ -194,6 +288,9 @@ class DevSpacesServerStepView( } private fun tokenPanel() = panel { + row { + cell(tokenSuggestionLabel).align(Align.FILL) + } row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { cell(tfToken).align(Align.FILL) } @@ -334,6 +431,9 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() updateAuthUiState() + setupTokenSuggestionLabel() + startClipboardPolling() + checkClipboardForToken() showTokenCheckbox.addActionListener { tfToken.echoChar = if (showTokenCheckbox.isSelected) 0.toChar() else '•' @@ -345,6 +445,10 @@ class DevSpacesServerStepView( } override fun onDispose() { + tokenLabelListener?.let { + tokenSuggestionLabel.removeMouseListener(it) + } + stopClipboardPolling() stopKubeconfigMonitor() super.onDispose() } From 3169baf22c4e6635f3127109d5ea1fb4b47b856f Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Fri, 6 Mar 2026 20:24:42 +0100 Subject: [PATCH 04/32] feat: Gateway: CRW-8927 - Simplify login in to the OCP cluster from the Gateway plugin FixUp for the TLS Certificate resolve - now users get asked if they trust a new cluster's TLS Certificate Signed-off-by: Victor Rubezhny Assisted-by: OpenAI ChatGPT --- .../gateway/auth/tls/CapturingTrustManager.kt | 8 +++++++- .../gateway/auth/tls/DefaultTlsTrustManager.kt | 17 ----------------- .../gateway/auth/tls/SslContextFactory.kt | 4 ++-- .../view/steps/DevSpacesServerStepView.kt | 2 +- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt index 8f596357..e9324cd7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.kt @@ -12,9 +12,12 @@ package com.redhat.devtools.gateway.auth.tls import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException import javax.net.ssl.X509TrustManager -class CapturingTrustManager : X509TrustManager { +class CapturingTrustManager( + private val failIfUntrusted: Boolean = false +) : X509TrustManager { @Volatile var serverCertificateChain: Array? = null @@ -22,6 +25,9 @@ class CapturingTrustManager : X509TrustManager { override fun checkServerTrusted(chain: Array, authType: String) { serverCertificateChain = chain + if (failIfUntrusted) { + throw SSLHandshakeException("Forced handshake failure for certificate testing") + } } override fun checkClientTrusted(chain: Array, authType: String) {} 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 f1376335..0e843135 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -31,28 +31,23 @@ class DefaultTlsTrustManager( val serverUri = URI(serverUrl) - // 1️⃣ Locate kubeconfig cluster val namedCluster = KubeConfigTlsUtils.findClusterByServer( serverUrl, kubeConfigProvider() ) - // 2️⃣ insecure-skip-tls-verify if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { return SslContextFactory.insecure() } - // 3️⃣ Load all trusted certs (kubeconfig + session + persistent) val trustedCerts = mutableListOf() - namedCluster?.let { trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) } trustedCerts += sessionTrustStore.get(serverUrl) - // load persistent keystore cert for this host only val keyStore = persistentKeyStore.loadOrCreate() val persistentAlias = "host:${serverUri.host}" @@ -61,7 +56,6 @@ class DefaultTlsTrustManager( trustedCerts += persistentCert } - // 4️⃣ If we have trusted certs — try normal handshake first if (trustedCerts.isNotEmpty()) { try { val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) @@ -72,14 +66,12 @@ class DefaultTlsTrustManager( } } - // 5️⃣ Capture server certificate chain val captureContext = SslContextFactory.captureOnly() try { TlsProbe.connect(serverUri, captureContext.sslContext) return captureContext // should not normally succeed } catch (e: SSLHandshakeException) { - val chain = (captureContext.trustManager as? CapturingTrustManager) ?.serverCertificateChain ?.toList() @@ -100,30 +92,22 @@ class DefaultTlsTrustManager( problem = problem ) - // 6️⃣ Ask UI layer val decision = decisionHandler(info) - if (!decision.trusted) { throw TlsTrustRejectedException() } - // 7️⃣ Persist based on scope when (decision.scope) { TlsTrustScope.SESSION_ONLY -> { sessionTrustStore.put(serverUrl, listOf(trustAnchor)) } TlsTrustScope.PERMANENT -> { - - // session sessionTrustStore.put(serverUrl, listOf(trustAnchor)) - // kubeconfig if (namedCluster != null) { kubeConfigWriter(namedCluster, listOf(trustAnchor)) } - - // persistent keystore (host-scoped) KeyStoreUtils.addCertificate( keyStore, persistentAlias, @@ -135,7 +119,6 @@ class DefaultTlsTrustManager( null -> error("Trusted decision without scope") } - // 8️⃣ Return final trusted SSLContext val finalCerts = (trustedCerts + trustAnchor) .distinctBy { it.serialNumber } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt index 13247f1e..06953f8b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt @@ -64,8 +64,8 @@ object SslContextFactory { return TlsContext(sslContext, trustManager) } - fun captureOnly(): TlsContext { - val capturingTrustManager = CapturingTrustManager() + fun captureOnly(failIfUntrusted: Boolean = true): TlsContext { + val capturingTrustManager = CapturingTrustManager(failIfUntrusted) val sslContext = SSLContext.getInstance("TLS").apply { init( 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 140c67fb..c62e0d23 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 @@ -191,7 +191,7 @@ class DevSpacesServerStepView( text = "" foreground = JBColor.BLUE cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - isVisible = false + isVisible = false font = font.deriveFont(Font.ITALIC or Font.PLAIN) } From cbbdd79362cf94c4dfd2cea1a39c84e12f885950 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 11:23:40 +0100 Subject: [PATCH 05/32] fixed leaking httpclient Signed-off-by: Andre Dietisheim --- .../gateway/auth/code/OpenShiftAuthCodeFlow.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt index ce6cbe97..f2e86f82 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 @@ -48,19 +48,21 @@ class OpenShiftAuthCodeFlow( private val json = Json { ignoreUnknownKeys = true } - private fun discoveryClient(): HttpClient = + private val discoveryClient: HttpClient by lazy { HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build() + } - private fun noRedirectClient(): HttpClient = + private val noRedirectClient: HttpClient by lazy { HttpClient.newBuilder() .sslContext(sslContext) .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NEVER) .build() + } @Serializable private data class OAuthMetadata( @@ -77,7 +79,7 @@ class OpenShiftAuthCodeFlow( * Discover OAuth endpoints from the cluster. */ private suspend fun discoverOAuthMetadata(): OAuthMetadata { - val client = discoveryClient() + val client = discoveryClient val request = HttpRequest.newBuilder() .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) @@ -132,7 +134,7 @@ class OpenShiftAuthCodeFlow( } private suspend fun exchangeCodeForToken(code: String): SSOToken { - val httpClient = discoveryClient() + val httpClient = discoveryClient val basicAuth = "Basic " + Base64.getEncoder() .encodeToString("openshift-cli-client:".toByteArray(StandardCharsets.UTF_8)) @@ -178,7 +180,7 @@ class OpenShiftAuthCodeFlow( codeVerifier = CodeVerifier() state = State() - val httpClient = noRedirectClient() + val httpClient = noRedirectClient val redirectUri = URI( metadata.tokenEndpoint.replace( From b29f74475dc3e9ba5e9ebeee20972a25520a7b72 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 16:18:49 +0100 Subject: [PATCH 06/32] fixed blocking calls: - OpenShiftAuthCodeFlow, RedHatAuthCodeFlow: migrated all HttpClient.send() calls to sendAsync().await() to avoid thread blocking. - SandboxApi: converted methods to suspend functions and implemented sendAsync().await(). - DefaultTlsTrustManager: refactored the TlsTrustManager interface and implementation to move network probes and I/O to Dispatchers.IO. - IdeaSecureTokenStorage, JBPasswordSafeTokenStorage: moved PasswordSafe I/O operations to Dispatchers.IO. - DevSpacesServerStepView: Moved blocking KubeConfigUtils calls out of the UI thread using withContext(Dispatchers.IO). - OidcProviderMetadataResolver: Wrapped Nimbus SDK blocking calls with withContext(Dispatchers.IO). --- .../auth/code/IdeaSecureTokenStorage.kt | 20 ++++++++++++------- .../auth/code/JBPasswordSafeTokenStorage.kt | 13 +++++++++--- .../auth/code/OpenShiftAuthCodeFlow.kt | 12 ++++++----- .../gateway/auth/code/RedHatAuthCodeFlow.kt | 3 ++- .../auth/oidc/OidcProviderMetadataResolver.kt | 6 +++++- .../gateway/auth/sandbox/SandboxApi.kt | 13 ++++++------ .../sandbox/SandboxClusterAuthProvider.kt | 15 +++++++------- .../auth/tls/DefaultTlsTrustManager.kt | 13 ++++++++---- .../view/steps/DevSpacesServerStepView.kt | 16 ++++++++++----- 9 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt index 2939029e..c94e3524 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt @@ -16,6 +16,7 @@ package com.redhat.devtools.gateway.auth.code import com.intellij.credentialStore.CredentialAttributes import com.intellij.credentialStore.Credentials import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.coroutines.* import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -33,15 +34,18 @@ class IdeaSecureTokenStorage : SecureTokenStorage { override suspend fun saveToken(token: TokenModel) { val serialized = json.encodeToString(token) - PasswordSafe.instance.set( - attributes, - Credentials("sso", serialized) - ) + withContext(Dispatchers.IO) { + PasswordSafe.instance.set( + attributes, + Credentials("sso", serialized) + ) + } } override suspend fun loadToken(): TokenModel? { - val credentials = PasswordSafe.instance.get(attributes) - ?: return null + val credentials = withContext(Dispatchers.IO) { + PasswordSafe.instance.get(attributes) + } ?: return null val raw = credentials.password?.toString() ?: return null @@ -52,6 +56,8 @@ class IdeaSecureTokenStorage : SecureTokenStorage { } override suspend fun clearToken() { - PasswordSafe.instance.set(attributes, null) + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, null) + } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt index 5b42c8f0..6354f64e 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt @@ -15,6 +15,7 @@ import com.intellij.credentialStore.CredentialAttributes import com.intellij.credentialStore.Credentials import com.intellij.credentialStore.generateServiceName import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.coroutines.* import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -35,17 +36,23 @@ class JBPasswordSafeTokenStorage : SecureTokenStorage { json ) - PasswordSafe.instance.set(attributes, credentials) + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, credentials) + } } override suspend fun loadToken(): TokenModel? { - val credentials = PasswordSafe.instance.get(attributes) ?: return null + val credentials = withContext(Dispatchers.IO) { + PasswordSafe.instance.get(attributes) + } ?: return null val json = credentials.getPasswordAsString() ?: return null return Json.decodeFromString(json) } override suspend fun clearToken() { - PasswordSafe.instance.set(attributes, null) + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, null) + } } } 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 f2e86f82..8293d4c2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -18,6 +18,8 @@ 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 kotlinx.coroutines.future.await import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -86,7 +88,7 @@ class OpenShiftAuthCodeFlow( .GET() .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() if (response.statusCode() !in 200..299) { error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") } @@ -155,7 +157,7 @@ class OpenShiftAuthCodeFlow( .POST(HttpRequest.BodyPublishers.ofString(form)) .build() - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() if (response.statusCode() !in 200..299) { error("Token request failed: ${response.statusCode()}\n${response.body()}") } @@ -208,7 +210,7 @@ class OpenShiftAuthCodeFlow( .GET() .build() - var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() // Retry with Basic auth if (response.statusCode() == 401) { @@ -219,7 +221,7 @@ class OpenShiftAuthCodeFlow( .GET() .build() - response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() } if (response.statusCode() !in listOf(302, 303)) { @@ -269,7 +271,7 @@ class OpenShiftAuthCodeFlow( .POST(HttpRequest.BodyPublishers.ofString(form)) .build() - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() if (response.statusCode() != 200) { error("Token exchange failed: ${response.statusCode()} ${response.body()}") } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt index d5c8e517..bcf83728 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -22,6 +22,7 @@ import com.nimbusds.oauth2.sdk.pkce.CodeVerifier import com.nimbusds.openid.connect.sdk.AuthenticationRequest import com.nimbusds.openid.connect.sdk.Nonce import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import kotlinx.coroutines.future.await import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -106,7 +107,7 @@ class RedHatAuthCodeFlow( .POST(HttpRequest.BodyPublishers.ofString(form)) .build() - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() if (response.statusCode() !in 200..299) { error("Token request failed: ${response.statusCode()}\n${response.body()}") } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt index ccb710d4..a6bb0a08 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt @@ -14,6 +14,8 @@ package com.redhat.devtools.gateway.auth.oidc import com.nimbusds.oauth2.sdk.id.Issuer import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class OidcProviderMetadataResolver( authUrl: String @@ -27,7 +29,9 @@ class OidcProviderMetadataResolver( cached?.let { return it } val request = OIDCProviderConfigurationRequest(issuer) - val httpResponse = request.toHTTPRequest().send() + val httpResponse = withContext(Dispatchers.IO) { + request.toHTTPRequest().send() + } val metadata = OIDCProviderMetadata.parse(httpResponse.bodyAsJSONObject) cached = metadata diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt index 2df90d1b..67f19853 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.auth.sandbox +import kotlinx.coroutines.future.await import kotlinx.serialization.json.Json import java.net.URI import java.net.http.HttpClient @@ -31,17 +32,17 @@ class SandboxApi( ignoreUnknownKeys = true } - fun getSignUpStatus(ssoToken: String): SandboxSignupResponse? { + suspend fun getSignUpStatus(ssoToken: String): SandboxSignupResponse? { val request = HttpRequest.newBuilder() .uri(URI.create("$baseUrl/api/v1/signup")) .header("Authorization", "Bearer $ssoToken") .GET() .build() - val response = httpClient.send( + val response = httpClient.sendAsync( request, HttpResponse.BodyHandlers.ofString() - ) + ).await() if (response.statusCode() != 200) { return null @@ -50,17 +51,17 @@ class SandboxApi( return json.decodeFromString(response.body()) } - fun signUp(ssoToken: String): Boolean { + suspend fun signUp(ssoToken: String): Boolean { val request = HttpRequest.newBuilder() .uri(URI.create("$baseUrl/api/v1/signup")) .header("Authorization", "Bearer $ssoToken") .POST(HttpRequest.BodyPublishers.noBody()) .build() - val response = httpClient.send( + val response = httpClient.sendAsync( request, HttpResponse.BodyHandlers.discarding() - ) + ).await() return response.statusCode() in 200..299 } 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 e84ffdf1..507a53bd 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 @@ -21,6 +21,7 @@ import io.kubernetes.client.openapi.models.V1Secret import io.kubernetes.client.openapi.models.V1ServiceAccount import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import kotlinx.coroutines.* import java.util.* import java.util.concurrent.TimeUnit @@ -30,7 +31,7 @@ class SandboxClusterAuthProvider( SandboxDefaults.SANDBOX_API_TIMEOUT_MS ) ) { - fun authenticate(ssoToken: SSOToken): TokenModel { + suspend fun authenticate(ssoToken: SSOToken): TokenModel { val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) ?: error("Sandbox not available") @@ -61,24 +62,24 @@ class SandboxClusterAuthProvider( ) } - private fun ensurePipelineServiceAccount(api: CoreV1Api, namespace: String): V1ServiceAccount { + private suspend fun ensurePipelineServiceAccount(api: CoreV1Api, namespace: String): V1ServiceAccount = withContext(Dispatchers.IO) { val saList = api.listNamespacedServiceAccount(namespace).execute() ?: error("Failed to list ServiceAccounts") - return saList.items.firstOrNull { it.metadata?.name == "pipeline" } + saList.items.firstOrNull { it.metadata?.name == "pipeline" } ?: api.createNamespacedServiceAccount( namespace, V1ServiceAccount().metadata(V1ObjectMeta().name("pipeline")) ).execute() ?: error("Failed to create pipeline ServiceAccount") } - private fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret { + private suspend fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret = withContext(Dispatchers.IO) { val secretName = "pipeline-secret-${sa.metadata?.name}" val secretList = api.listNamespacedSecret(namespace).execute() ?: error("Failed to list Secrets") secretList.items.firstOrNull { it.metadata?.name == secretName && it.data?.containsKey("token") == true } - ?.let { return it } + ?.let { return@withContext it } val secret = V1Secret().metadata( V1ObjectMeta() @@ -91,8 +92,8 @@ class SandboxClusterAuthProvider( repeat(30) { val s = api.readNamespacedSecret(secretName, namespace).execute() - if (s.data?.containsKey("token") == true) return s - Thread.sleep(1000) + if (s.data?.containsKey("token") == true) return@withContext s + delay(1000) } error("Pipeline token secret not populated") 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 0e843135..1c845c82 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -13,13 +13,14 @@ package com.redhat.devtools.gateway.auth.tls import com.redhat.devtools.gateway.kubeconfig.KubeConfigNamedCluster import io.kubernetes.client.util.KubeConfig +import kotlinx.coroutines.* import java.net.URI import java.security.cert.X509Certificate import javax.net.ssl.SSLHandshakeException class DefaultTlsTrustManager( - private val kubeConfigProvider: () -> List, - private val kubeConfigWriter: (KubeConfigNamedCluster, List) -> Unit, + private val kubeConfigProvider: suspend () -> List, + private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List) -> Unit, private val sessionTrustStore: SessionTlsTrustStore, private val persistentKeyStore: PersistentKeyStore ) : TlsTrustManager { @@ -59,7 +60,9 @@ class DefaultTlsTrustManager( if (trustedCerts.isNotEmpty()) { try { val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) - TlsProbe.connect(serverUri, tlsContext.sslContext) + withContext(Dispatchers.IO) { + TlsProbe.connect(serverUri, tlsContext.sslContext) + } return tlsContext } catch (e: SSLHandshakeException) { // Certificate changed or invalid → continue to capture @@ -69,7 +72,9 @@ class DefaultTlsTrustManager( val captureContext = SslContextFactory.captureOnly() try { - TlsProbe.connect(serverUri, captureContext.sslContext) + withContext(Dispatchers.IO) { + TlsProbe.connect(serverUri, captureContext.sslContext) + } return captureContext // should not normally succeed } catch (e: SSLHandshakeException) { val chain = (captureContext.trustManager as? CapturingTrustManager) 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 c62e0d23..cd26745c 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 @@ -519,9 +519,11 @@ class DevSpacesServerStepView( private fun onClustersChanged(): suspend (List) -> Unit = { updatedClusters -> this.allClusters = updatedClusters if (updatedClusters.isNotEmpty()) { + val kubeConfigCurrentCluster = withContext(Dispatchers.IO) { + KubeConfigUtils.getCurrentClusterName() + } ApplicationManager.getApplication().invokeLater( { - val kubeConfigCurrentCluster = KubeConfigUtils.getCurrentClusterName() val previouslySelected = tfServer.selectedItem as? Cluster? setClusters(updatedClusters) setSelectedCluster( @@ -794,12 +796,16 @@ class DevSpacesServerStepView( private val tlsTrustManager = DefaultTlsTrustManager( kubeConfigProvider = { - KubeConfigUtils.getAllConfigs( - KubeConfigUtils.getAllConfigFiles() - ) + withContext(Dispatchers.IO) { + KubeConfigUtils.getAllConfigs( + KubeConfigUtils.getAllConfigFiles() + ) + } }, kubeConfigWriter = { namedCluster, certs -> - KubeConfigTlsWriter.write(namedCluster, certs) + withContext(Dispatchers.IO) { + KubeConfigTlsWriter.write(namedCluster, certs) + } }, sessionTrustStore = sessionTrustStore, persistentKeyStore = persistentKeyStore From 582a5dd437f6c03ba00074ef8d4399b78c7b633c Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 17:04:32 +0100 Subject: [PATCH 07/32] avoid blocking thread requesting secret --- .../sandbox/SandboxClusterAuthProvider.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 507a53bd..ba283bd1 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 @@ -22,8 +22,8 @@ import io.kubernetes.client.openapi.models.V1ServiceAccount import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.credentials.AccessTokenAuthentication import kotlinx.coroutines.* -import java.util.* import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds class SandboxClusterAuthProvider( private val sandboxApi: SandboxApi = SandboxApi( @@ -90,15 +90,24 @@ class SandboxClusterAuthProvider( api.createNamespacedSecret(namespace, secret).execute() - repeat(30) { - val s = api.readNamespacedSecret(secretName, namespace).execute() - if (s.data?.containsKey("token") == true) return@withContext s - delay(1000) + if (requestSecret(secretName, namespace, api)) { + return@withContext secret } error("Pipeline token secret not populated") } + private suspend fun requestSecret(secretName: String, namespace: String, api: CoreV1Api): Boolean { + repeat(30) { + val secret = api.readNamespacedSecret(secretName, namespace).execute() + if (secret.data?.containsKey("token") == true) { + return true + } + delay(1000.milliseconds) + } + return false + } + private fun extractToken(secret: V1Secret): String { val tokenBytes = secret.data?.get("token") ?: error("Token missing in secret") return String(tokenBytes, Charsets.UTF_8) From 33aff8a57413d65cb47891cf9fb705c0b9dd9747 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 17:07:17 +0100 Subject: [PATCH 08/32] ensure digest is OpenSSL compliant lowercase --- .../redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c845c82..6b0a632b 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 @@ -135,6 +135,6 @@ class DefaultTlsTrustManager( private fun sha256Fingerprint(cert: X509Certificate): String { val digest = java.security.MessageDigest.getInstance("SHA-256") .digest(cert.encoded) - return digest.joinToString(":") { "%02X".format(it) } + return digest.joinToString(":") { "%02x".format(it) } } } From c37d7080a2e7e147cc334c4a9d07a50883de5a9c Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 17:14:50 +0100 Subject: [PATCH 09/32] added logging to OpenShiftAuthSessionManager, RedHatAuthSessionManager --- .../session/OpenShiftAuthSessionManager.kt | 42 +++++++++++++++++-- .../auth/session/RedHatAuthSessionManager.kt | 34 +++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt index ca7a75df..54871281 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -15,6 +15,7 @@ import com.intellij.notification.Notification import com.intellij.notification.NotificationType import com.intellij.notification.Notifications import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.auth.code.Parameters @@ -29,6 +30,7 @@ import kotlinx.coroutines.* import java.net.URI import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLContext +import kotlin.time.Duration.Companion.milliseconds const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L @@ -65,33 +67,42 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { } override suspend fun initialize() { + thisLogger().info("OpenShiftAuthSessionManager initialized") notifyChanged() } suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") return try { - withTimeout(timeoutMs) { deferred.await() } - } catch (_: TimeoutCancellationException) { + val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } + thisLogger().info("Login result received successfully") + token + } catch (e: TimeoutCancellationException) { + thisLogger().warn("Login timed out after ${timeoutMs}ms") throw SsoLoginException.Timeout } } override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { if (apiServerUrl == null) { + thisLogger().error("API Server URL is null") throw IllegalStateException("Provide API Server URL") } if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login already in progress") throw IllegalStateException("Login already in progress") } + thisLogger().info("Starting OpenShift login for API server: $apiServerUrl") pendingLogin = CompletableDeferred() try { notifyChanged() callbackServer.stop() val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") authFlow = OpenShiftAuthCodeFlow( apiServerUrl = apiServerUrl, @@ -100,21 +111,27 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { ) val request = authFlow.startAuthFlow() + thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") CoroutineScope(Dispatchers.IO).launch { try { + thisLogger().debug("Waiting for OAuth callback...") val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) if (params == null) { + thisLogger().warn("OAuth callback timed out or was cancelled") pendingLogin?.completeExceptionally(SsoLoginException.Timeout) notifyLoginCancelled() return@launch } + thisLogger().debug("OAuth callback received, handling...") val token: SSOToken = authFlow.handleCallback(params) currentToken = token + thisLogger().info("OpenShift login successful for account: ${token.accountLabel}") pendingLogin?.complete(token) } catch (e: Exception) { + thisLogger().error("OpenShift login failed", e) pendingLogin?.completeExceptionally( SsoLoginException.Failed(e.message ?: "OpenShift login failed") ) @@ -126,6 +143,7 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { return request.authorizationUri } catch (e: Exception) { + thisLogger().error("Failed to start OpenShift login", e) pendingLogin?.completeExceptionally(e) pendingLogin = null cancelLogin() @@ -134,6 +152,7 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { } private suspend fun cancelLogin() { + thisLogger().debug("Cancelling login") loginInProgress.set(false) notifyChanged() callbackServer.stop() @@ -151,14 +170,25 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { } override suspend fun getValidToken(): SSOToken? { - val token = currentToken ?: return null - if (!token.isExpired()) return token + val token = currentToken + if (token == null) { + thisLogger().debug("No current token available") + return null + } + + if (!token.isExpired()) { + thisLogger().debug("Returning valid token for account: ${token.accountLabel}") + return token + } + thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") logout() return null } override suspend fun logout() { + val account = currentToken?.accountLabel + thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") currentToken = null tokenStorage.clearToken() notifyChanged() @@ -175,9 +205,11 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { sslContext: SSLContext ): SSOToken { if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login with credentials already in progress") throw IllegalStateException("Login already in progress") } + thisLogger().info("Starting OpenShift credential login for user: $username at $apiServerUrl") try { notifyChanged() @@ -195,8 +227,10 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { ) currentToken = token + thisLogger().info("OpenShift credential login successful for account: ${token.accountLabel}") return token } catch (e: Exception) { + thisLogger().error("OpenShift credential login failed for user: $username", e) throw SsoLoginException.Failed(e.message ?: "OpenShift credential login failed") } finally { loginInProgress.set(false) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index fe7453e4..f60dd294 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -17,6 +17,7 @@ import com.intellij.notification.Notification import com.intellij.notification.NotificationType import com.intellij.notification.Notifications import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage import com.redhat.devtools.gateway.auth.code.RedHatAuthCodeFlow import com.redhat.devtools.gateway.auth.code.SSOToken @@ -32,6 +33,7 @@ import kotlinx.coroutines.* import java.net.URI import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLContext +import kotlin.time.Duration.Companion.milliseconds const val LOGIN_TIMEOUT_MS = 2 * 60_000L @@ -78,6 +80,7 @@ class RedHatAuthSessionManager(): AuthSessionManager { * Called once on plugin startup. */ override suspend fun initialize() { + thisLogger().info("RedHatAuthSessionManager initialized") notifyChanged() } @@ -87,11 +90,15 @@ class RedHatAuthSessionManager(): AuthSessionManager { val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") return try { - withTimeout(timeoutMs) { + val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } - } catch (_: TimeoutCancellationException) { + thisLogger().info("Login result received successfully") + token + } catch (e: TimeoutCancellationException) { + thisLogger().warn("Login timed out after ${timeoutMs}ms") throw SsoLoginException.Timeout } } @@ -101,9 +108,11 @@ class RedHatAuthSessionManager(): AuthSessionManager { */ override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login already in progress") throw IllegalStateException("Login already in progress") } + thisLogger().info("Starting Red Hat SSO login") pendingLogin = CompletableDeferred() try { @@ -111,6 +120,7 @@ class RedHatAuthSessionManager(): AuthSessionManager { callbackServer.stop() val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") authFlow = RedHatAuthCodeFlow( clientId = authConfig.clientId, @@ -120,10 +130,14 @@ class RedHatAuthSessionManager(): AuthSessionManager { ) val request = authFlow.startAuthFlow() + thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") + CoroutineScope(Dispatchers.IO).launch { try { + thisLogger().debug("Waiting for OAuth callback...") val params = callbackServer.awaitCallback(LOGIN_TIMEOUT_MS) if (params == null) { + thisLogger().warn("OAuth callback timed out or was cancelled") pendingLogin?.completeExceptionally( SsoLoginException.Timeout ) @@ -132,11 +146,14 @@ class RedHatAuthSessionManager(): AuthSessionManager { return@launch } + thisLogger().debug("OAuth callback received, handling...") val token = authFlow.handleCallback(params) currentToken = token + thisLogger().info("Red Hat SSO login successful for account: ${token.accountLabel}") pendingLogin?.complete(token) } catch (e: Exception) { + thisLogger().error("Red Hat SSO login failed", e) pendingLogin?.completeExceptionally( SsoLoginException.Failed(e.message ?: "SSO login failed") ) @@ -148,6 +165,7 @@ class RedHatAuthSessionManager(): AuthSessionManager { return request.authorizationUri } catch (e: Exception) { + thisLogger().error("Failed to start Red Hat SSO login", e) pendingLogin?.completeExceptionally(e) pendingLogin = null cancelLogin() @@ -161,10 +179,12 @@ class RedHatAuthSessionManager(): AuthSessionManager { password: String, sslContext: SSLContext ): SSOToken { + thisLogger().warn("Credential login not supported for Red Hat SSO") error("Not supported") } private suspend fun cancelLogin() { + thisLogger().debug("Cancelling login") loginInProgress.set(false) notifyChanged() callbackServer.stop() @@ -186,17 +206,25 @@ class RedHatAuthSessionManager(): AuthSessionManager { * Refreshes automatically if possible. */ override suspend fun getValidToken(): SSOToken? { - val token = currentToken ?: return null + val token = currentToken + if (token == null) { + thisLogger().debug("No current token available") + return null + } if (!token.isExpired()) { + thisLogger().debug("Returning valid token for account: ${token.accountLabel}") return token } + thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") logout() return null } override suspend fun logout() { + val account = currentToken?.accountLabel + thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") currentToken = null tokenStorage.clearToken() notifyChanged() From f6e0d16d28fb0fc48c3a4efb8b9d1fd292f62129 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 17:33:37 +0100 Subject: [PATCH 10/32] created AbstractAuthSessionManager as base class for OpenShiftAuthSessionManager, RedHatAuthSessionManager --- .../session/AbstractAuthSessionManager.kt | 202 ++++++++++++++++++ .../auth/session/AuthSessionManager.kt | 3 + .../session/OpenShiftAuthSessionManager.kt | 80 +------ .../auth/session/RedHatAuthSessionManager.kt | 89 +------- 4 files changed, 210 insertions(+), 164 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt new file mode 100644 index 00000000..b79e02f5 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt @@ -0,0 +1,202 @@ +/* + * 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.session + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.server.CallbackServer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds + +/** + * Abstract base class for authentication session managers. + * + * Provides common functionality for OAuth-based authentication including: + * - Token storage and retrieval with expiration handling + * - Login state management with concurrent access protection + * - Session listener notifications + * - Callback server lifecycle management + * + * ## Thread Safety + * This class is designed for concurrent access: + * - Login state is protected by [AtomicBoolean] + * - Token access is protected by [Mutex] + * - Listener notifications are fail-safe and copy-on-iterate + * + * ## Subclass Responsibilities + * Implementations must provide: + * - [initialize]: One-time setup on plugin startup + * - [startLogin]: Provider-specific OAuth flow initialization + * - [loginWithCredentials]: Provider-specific credential-based login (or throw UnsupportedOperationException) + * - [callbackServer]: The callback server instance for OAuth flows + * + * @param tokenStorage Storage mechanism for persisting tokens securely. Defaults to [JBPasswordSafeTokenStorage] + */ +abstract class AbstractAuthSessionManager( + protected val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() +) : AuthSessionManager { + + protected abstract val callbackServer: CallbackServer + + private val listeners = mutableSetOf() + private val tokenMutex = Mutex() + private var _currentToken: SSOToken? = null + + protected var currentToken: SSOToken? + get() = _currentToken + set(value) { _currentToken = value } + + protected val loginInProgress = AtomicBoolean(false) + protected var pendingLogin: CompletableDeferred? = null + + /** + * Checks if a login operation is currently in progress. + * + * @return true if login is in progress, false otherwise + */ + fun isLoginInProgress(): Boolean = loginInProgress.get() + + /** + * Adds a session change listener. + * Thread-safe and can be called during listener notification. + * + * @param listener The listener to add + */ + fun addListener(listener: AuthSessionListener) { + synchronized(listeners) { + listeners += listener + } + } + + /** + * Removes a session change listener. + * Thread-safe and can be called during listener notification. + * + * @param listener The listener to remove + */ + fun removeListener(listener: AuthSessionListener) { + synchronized(listeners) { + listeners -= listener + } + } + + /** + * Notifies all registered listeners of a session change. + * Notification failures are logged but do not prevent other listeners from being notified. + */ + protected fun notifyChanged() { + val listenersCopy = synchronized(listeners) { listeners.toList() } + listenersCopy.forEach { listener -> + try { + listener.sessionChanged() + } catch (e: Exception) { + thisLogger().error("Session listener notification failed", e) + } + } + } + + /** + * Awaits the result of a login operation started via [startLogin]. + * + * @param timeoutMs Maximum time to wait in milliseconds + * @return The received SSO token + * @throws IllegalStateException if login was not started + * @throws SsoLoginException.Timeout if the operation times out + */ + override suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") + return try { + val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } + thisLogger().info("Login result received successfully") + token + } catch (e: TimeoutCancellationException) { + thisLogger().warn("Login timed out after ${timeoutMs}ms") + throw SsoLoginException.Timeout + } + } + + /** + * Cancels the current login operation and cleans up resources. + */ + protected suspend fun cancelLogin() { + thisLogger().debug("Cancelling login") + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + /** + * Returns a valid (non-expired) token or null. + * If the current token is expired, automatically logs out and returns null. + * + * @return A valid token or null if not logged in or token is expired + */ + override suspend fun getValidToken(): SSOToken? = tokenMutex.withLock { + val token = currentToken + if (token == null) { + thisLogger().debug("No current token available") + return null + } + + if (!token.isExpired()) { + thisLogger().debug("Returning valid token for account: ${token.accountLabel}") + return token + } + + thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") + // Call private logout method to avoid deadlock (already holding tokenMutex) + doLogout() + return null + } + + /** + * Logs out the current user by clearing the token from storage and memory. + * Notifies all registered listeners of the session change. + */ + override suspend fun logout() = tokenMutex.withLock { + doLogout() + } + + /** + * Internal logout implementation that doesn't acquire the lock. + * Must be called while holding tokenMutex. + */ + private suspend fun doLogout() { + val account = currentToken?.accountLabel + thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + /** + * Checks if a user is currently logged in. + * + * @return true if a token exists (regardless of expiration), false otherwise + */ + override fun isLoggedIn(): Boolean = currentToken != null + + /** + * Returns the account label of the current token. + * + * @return The account label or null if not logged in + */ + override fun currentAccount(): String? = currentToken?.accountLabel +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt index 748d4322..81ac7473 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -23,6 +23,9 @@ interface AuthSessionManager { /** Starts login and returns browser URL */ suspend fun startLogin(apiServerUrl: String? = null, sslContext: SSLContext): URI + /** Awaits for the browser login result */ + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken + /** Starts login using the given credentials and returns a valid token */ suspend fun loginWithCredentials(apiServerUrl: String, username: String, password: String, sslContext: SSLContext): SSOToken diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt index 54871281..984bae3b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -16,11 +16,9 @@ import com.intellij.notification.NotificationType import com.intellij.notification.Notifications import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow import com.redhat.devtools.gateway.auth.code.Parameters import com.redhat.devtools.gateway.auth.code.SSOToken -import com.redhat.devtools.gateway.auth.code.SecureTokenStorage import com.redhat.devtools.gateway.auth.config.AuthType import com.redhat.devtools.gateway.auth.server.CallbackServer import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer @@ -28,62 +26,26 @@ import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder import com.redhat.devtools.gateway.auth.server.ServerConfigProvider import kotlinx.coroutines.* import java.net.URI -import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLContext -import kotlin.time.Duration.Companion.milliseconds const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L @Service(Service.Level.APP) -class OpenShiftAuthSessionManager() : AuthSessionManager { - - private val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() +class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { private val serverConfig = runBlocking { - ServerConfigProvider.getServerConfig(AuthType.SSO_OPENSHIFT) // or another type if you distinguish + ServerConfigProvider.getServerConfig(AuthType.SSO_OPENSHIFT) } - private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + override val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) private lateinit var authFlow: OpenShiftAuthCodeFlow - private val listeners = mutableSetOf() - private var currentToken: SSOToken? = null - private val loginInProgress = AtomicBoolean(false) - private var pendingLogin: CompletableDeferred? = null - - fun isLoginInProgress(): Boolean = loginInProgress.get() - - fun addListener(listener: AuthSessionListener) { - listeners += listener - } - - fun removeListener(listener: AuthSessionListener) { - listeners -= listener - } - - private fun notifyChanged() { - listeners.forEach { it.sessionChanged() } - } - override suspend fun initialize() { thisLogger().info("OpenShiftAuthSessionManager initialized") notifyChanged() } - suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { - val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") - thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") - return try { - val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } - thisLogger().info("Login result received successfully") - token - } catch (e: TimeoutCancellationException) { - thisLogger().warn("Login timed out after ${timeoutMs}ms") - throw SsoLoginException.Timeout - } - } - override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { if (apiServerUrl == null) { thisLogger().error("API Server URL is null") @@ -151,13 +113,6 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { } } - private suspend fun cancelLogin() { - thisLogger().debug("Cancelling login") - loginInProgress.set(false) - notifyChanged() - callbackServer.stop() - } - private fun notifyLoginCancelled() { Notifications.Bus.notify( Notification( @@ -169,35 +124,6 @@ class OpenShiftAuthSessionManager() : AuthSessionManager { ) } - override suspend fun getValidToken(): SSOToken? { - val token = currentToken - if (token == null) { - thisLogger().debug("No current token available") - return null - } - - if (!token.isExpired()) { - thisLogger().debug("Returning valid token for account: ${token.accountLabel}") - return token - } - - thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") - logout() - return null - } - - override suspend fun logout() { - val account = currentToken?.accountLabel - thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") - currentToken = null - tokenStorage.clearToken() - notifyChanged() - } - - override fun isLoggedIn(): Boolean = currentToken != null - - override fun currentAccount(): String? = currentToken?.accountLabel - override suspend fun loginWithCredentials( apiServerUrl: String, username: String, diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index f60dd294..08add759 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -18,10 +18,8 @@ import com.intellij.notification.NotificationType import com.intellij.notification.Notifications import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.thisLogger -import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage import com.redhat.devtools.gateway.auth.code.RedHatAuthCodeFlow import com.redhat.devtools.gateway.auth.code.SSOToken -import com.redhat.devtools.gateway.auth.code.SecureTokenStorage import com.redhat.devtools.gateway.auth.config.AuthConfig import com.redhat.devtools.gateway.auth.config.AuthType import com.redhat.devtools.gateway.auth.oidc.OidcProviderMetadataResolver @@ -31,23 +29,18 @@ import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder import com.redhat.devtools.gateway.auth.server.ServerConfigProvider import kotlinx.coroutines.* import java.net.URI -import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLContext -import kotlin.time.Duration.Companion.milliseconds const val LOGIN_TIMEOUT_MS = 2 * 60_000L @Service(Service.Level.APP) -class RedHatAuthSessionManager(): AuthSessionManager { - - private val tokenStorage: SecureTokenStorage = - JBPasswordSafeTokenStorage() +class RedHatAuthSessionManager : AbstractAuthSessionManager() { private val serverConfig = runBlocking { ServerConfigProvider.getServerConfig(AuthType.SSO_REDHAT) } - private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + override val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) private val authConfig = AuthConfig() @@ -57,25 +50,6 @@ class RedHatAuthSessionManager(): AuthSessionManager { private lateinit var authFlow: RedHatAuthCodeFlow - private val listeners = mutableSetOf() - private var currentToken: SSOToken? = null - - private val loginInProgress = AtomicBoolean(false) - - fun isLoginInProgress(): Boolean = loginInProgress.get() - - fun addListener(listener: AuthSessionListener) { - listeners += listener - } - - fun removeListener(listener: AuthSessionListener) { - listeners -= listener - } - - private fun notifyChanged() { - listeners.forEach { it.sessionChanged() } - } - /** * Called once on plugin startup. */ @@ -84,25 +58,6 @@ class RedHatAuthSessionManager(): AuthSessionManager { notifyChanged() } - private var pendingLogin: CompletableDeferred? = null - - suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { - val deferred = pendingLogin - ?: throw IllegalStateException("Login was not started") - - thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") - return try { - val token = withTimeout(timeoutMs.milliseconds) { - deferred.await() - } - thisLogger().info("Login result received successfully") - token - } catch (e: TimeoutCancellationException) { - thisLogger().warn("Login timed out after ${timeoutMs}ms") - throw SsoLoginException.Timeout - } - } - /** * Starts the login process and returns browser URL. */ @@ -183,13 +138,6 @@ class RedHatAuthSessionManager(): AuthSessionManager { error("Not supported") } - private suspend fun cancelLogin() { - thisLogger().debug("Cancelling login") - loginInProgress.set(false) - notifyChanged() - callbackServer.stop() - } - private fun notifyLoginCancelled() { Notifications.Bus.notify( Notification( @@ -200,37 +148,4 @@ class RedHatAuthSessionManager(): AuthSessionManager { ) ) } - - /** - * Returns a valid (non-expired) token or null. - * Refreshes automatically if possible. - */ - override suspend fun getValidToken(): SSOToken? { - val token = currentToken - if (token == null) { - thisLogger().debug("No current token available") - return null - } - - if (!token.isExpired()) { - thisLogger().debug("Returning valid token for account: ${token.accountLabel}") - return token - } - - thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") - logout() - return null - } - - override suspend fun logout() { - val account = currentToken?.accountLabel - thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") - currentToken = null - tokenStorage.clearToken() - notifyChanged() - } - - override fun isLoggedIn(): Boolean = currentToken != null - - override fun currentAccount(): String? = currentToken?.accountLabel } From 74b99f07061fc0b3c7c291fb62fc05518e0dacfa Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 18:23:21 +0100 Subject: [PATCH 11/32] dont freeze UI when DevSpacesServerStepView.onNext() --- .../view/steps/DevSpacesServerStepView.kt | 216 +++++++++--------- 1 file changed, 114 insertions(+), 102 deletions(-) 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 cd26745c..96203789 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 @@ -17,10 +17,12 @@ 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.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.platform.ide.progress.ModalTaskOwner +import com.intellij.platform.ide.progress.runWithModalProgressBlocking +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.platform.util.progress.reportRawProgress import com.intellij.ui.JBColor import com.intellij.ui.components.* import com.intellij.ui.dsl.builder.Align @@ -47,6 +49,7 @@ import com.redhat.devtools.gateway.openshift.Cluster import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.settings.DevSpacesSettings +import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu @@ -551,58 +554,58 @@ class DevSpacesServerStepView( onDispose() - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - val indicator = ProgressManager.getInstance().progressIndicator + try { + runWithModalProgressBlocking(ModalTaskOwner.component(component), "Connecting to OpenShift...") { + withContext(Dispatchers.IO) { + reportRawProgress { reporter -> + reporter.text("Connecting to cluster...") - try { - indicator.text = "Connecting to cluster..." + val tlsContext = resolveSslContext(server) - val tlsContext = runBlocking { - resolveSslContext(server) - } + val certAuthorityData = tfCertAuthorityData.text - val certAuthorityData = tfCertAuthorityData.text + when (authMethod) { + AuthMethod.TOKEN -> { + reporter.text("Validating token...") - when (authMethod) { - AuthMethod.TOKEN -> { - indicator.text = "Validating token..." + val token = String(tfToken.password) - val token = String(tfToken.password) + val client = createValidatedApiClient( + server, certAuthorityData, + token, null, null, tlsContext, + "Authentication failed: invalid server URL or token." + ) - val client = createValidatedApiClient(server, certAuthorityData, - token, null, null, tlsContext, - "Authentication failed: invalid server URL or token.") + saveKubeconfig(selectedCluster, token, reporter) + devSpacesContext.client = client + } - saveKubeconfig(selectedCluster, token, indicator) - devSpacesContext.client = client - } + AuthMethod.CLIENT_CERTIFICATE -> { + reporter.text("Validating client certificate...") - AuthMethod.CLIENT_CERTIFICATE -> { - indicator.text = "Validating client certificate..." + val clientCertPem = tfClientCert.text + val clientKeyPem = tfClientKey.text - val clientCertPem = tfClientCert.text - val clientKeyPem = tfClientKey.text + val client = createValidatedApiClient( + server, certAuthorityData, + null, clientCertPem, clientKeyPem, tlsContext, + "Authentication failed: invalid server URL or token." + ) - val client = createValidatedApiClient(server, certAuthorityData, - null, clientCertPem, clientKeyPem, tlsContext, - "Authentication failed: invalid server URL or token.") + require(Projects(client).isAuthenticated()) { + "Authentication failed: invalid client certificate or key." + } - require(Projects(client).isAuthenticated()) { - "Authentication failed: invalid client certificate or key." + saveKubeconfig(selectedCluster, clientCertPem, clientKeyPem, reporter) + devSpacesContext.client = client } - saveKubeconfig(selectedCluster, clientCertPem, clientKeyPem, indicator) - devSpacesContext.client = client - } - - AuthMethod.OPENSHIFT_CREDENTIALS -> { - indicator.text = "Authenticating with OpenShift credentials..." + AuthMethod.OPENSHIFT_CREDENTIALS -> { + reporter.text("Authenticating with OpenShift credentials...") - val username = tfUsername.text - val password = String(tfPassword.password) + val username = tfUsername.text + val password = String(tfPassword.password) - val finalToken = runBlocking { val sessionManager = OpenShiftAuthSessionManager() val osToken = sessionManager.loginWithCredentials( @@ -612,105 +615,114 @@ class DevSpacesServerStepView( tlsContext.sslContext ) - TokenModel( + val finalToken = TokenModel( accessToken = osToken.accessToken, expiresAt = osToken.expiresAt, accountLabel = osToken.accountLabel, kind = AuthTokenKind.TOKEN, clusterApiUrl = selectedCluster.url ) - } - indicator.text = "Validating cluster access..." + reporter.text("Validating cluster access...") - val client = createValidatedApiClient(server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: invalid OpenShift credentials." - ) + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: invalid OpenShift credentials." + ) - tfToken.text = finalToken.accessToken - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) - devSpacesContext.client = client - } + withContext(Dispatchers.Main) { + tfToken.text = finalToken.accessToken + } + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + devSpacesContext.client = client + } - AuthMethod.OPENSHIFT -> { - indicator.text = "Authenticating with Openshift..." + AuthMethod.OPENSHIFT -> { + reporter.text("Authenticating with Openshift...") - val finalToken = runBlocking { val openshiftSSessionManager = OpenShiftAuthSessionManager() - val uri = openshiftSSessionManager.startLogin(selectedCluster.url, tlsContext.sslContext) - BrowserUtil.browse(uri) + val uri = openshiftSSessionManager.startLogin( + selectedCluster.url, + tlsContext.sslContext + ) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } - indicator.text = "Waiting for you to complete login in your browser..." - indicator.checkCanceled() + reporter.text("Waiting for you to complete login in your browser...") + ensureActive() - indicator.text = "Obtaining OpenShift access..." + reporter.text("Obtaining OpenShift access...") val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - TokenModel( + val finalToken = TokenModel( accessToken = osToken.accessToken, expiresAt = osToken.expiresAt, accountLabel = osToken.accountLabel, kind = AuthTokenKind.TOKEN, clusterApiUrl = selectedCluster.url ) - } - indicator.text = "Validating cluster access..." + reporter.text("Validating cluster access...") - val client = createValidatedApiClient(server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: token received from OpenShift Authenticator is invalid or expired.") + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired." + ) - tfToken.text = finalToken.accessToken - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) - devSpacesContext.client = client - } + withContext(Dispatchers.Main) { + tfToken.text = finalToken.accessToken + } + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + devSpacesContext.client = client + } - AuthMethod.REDHAT_SSO -> { - indicator.text = "Authenticating with Red Hat..." + AuthMethod.REDHAT_SSO -> { + reporter.text("Authenticating with Red Hat...") - val finalToken = runBlocking { val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) - BrowserUtil.browse(uri) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } - indicator.text = "Waiting for you to complete login in your browser..." - indicator.checkCanceled() + reporter.text("Waiting for you to complete login in your browser...") + ensureActive() val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - indicator.text = "Obtaining OpenShift access..." + reporter.text("Obtaining OpenShift access...") val sandboxAuth = SandboxClusterAuthProvider() - sandboxAuth.authenticate(ssoToken) - } + val finalToken = sandboxAuth.authenticate(ssoToken) - indicator.text = "Validating cluster access..." + reporter.text("Validating cluster access...") - val client = createValidatedApiClient(server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster.") + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) - // Do not save SSO tokens - if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + } + devSpacesContext.client = client } - devSpacesContext.client = client } } - - success = true - } catch (e: Exception) { - Dialogs.error( - e.message ?: "Unable to connect to the cluster", - "Connection Failed" - ) - throw e } - }, - "Connecting to OpenShift...", - true, - null - ) + } + success = true + } catch (e: Exception) { + if (!e.isCancellationException()) { + Dialogs.error( + e.message ?: "Unable to connect to the cluster", + "Connection Failed" + ) + } + } if (success) { settings.save(selectedCluster) @@ -818,11 +830,11 @@ class DevSpacesServerStepView( ) } - private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { + private fun saveKubeconfig(cluster: Cluster?, token: String?, reporter: RawProgressReporter) { if (!saveToKubeconfig || cluster == null || token.isNullOrBlank()) return try { - indicator.text = "Updating Kube config..." + reporter.text("Updating Kube config...") KubeConfigUpdate .create( cluster.name.trim(), @@ -835,11 +847,11 @@ class DevSpacesServerStepView( } } - private fun saveKubeconfig(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, indicator: ProgressIndicator) { + private fun saveKubeconfig(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, reporter: RawProgressReporter) { if (!saveToKubeconfig || cluster == null || clientCertPem.isNullOrBlank() || clientKeyPem.isNullOrBlank()) return try { - indicator.text = "Updating Kube config..." + reporter.text("Updating Kube config...") KubeConfigUpdate .create( cluster.name.trim(), From debf651100f41266dfc5b3521bbf2df259b1cadc Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 18:33:19 +0100 Subject: [PATCH 12/32] simplified if statement, removed '== true' --- .../devtools/gateway/view/steps/DevSpacesServerStepView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 96203789..3fe77369 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 @@ -172,7 +172,7 @@ class DevSpacesServerStepView( private fun suggestToken(token: String?) { ApplicationManager.getApplication().invokeLater ( { - if (token.isOpenShiftToken() == true) { + if (token.isOpenShiftToken()) { tokenSuggestionLabel.apply { text = "Token detected in clipboard. Click here to use it." isVisible = true From e2901552d0c9ccdbc02a4194febc4e1ef65f024c Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 18:39:21 +0100 Subject: [PATCH 13/32] extracted class ClipboardTokenMonitor --- .../devtools/gateway/util/ClipboardReader.kt | 48 ++ .../gateway/util/ClipboardTokenMonitor.kt | 163 ++++++ .../view/steps/DevSpacesServerStepView.kt | 85 +-- .../gateway/util/ClipboardTokenMonitorTest.kt | 489 ++++++++++++++++++ 4 files changed, 717 insertions(+), 68 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.kt new file mode 100644 index 00000000..6953ad97 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.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.util + +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor + +/** + * Interface for reading text content from the system clipboard. + * This abstraction allows for testing without requiring a display server. + */ +interface ClipboardReader { + /** + * Reads the current text content from the clipboard. + * + * @return The clipboard text or null if empty or not text + */ + fun readText(): String? +} + +/** + * Default implementation that reads from the system clipboard. + */ +class SystemClipboardReader : ClipboardReader { + override fun readText(): String? { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val contents = clipboard.getContents(null) ?: return null + + return try { + if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { + contents.getTransferData(DataFlavor.stringFlavor) as? String + } else { + null + } + } catch (_: Exception) { + null + }?.trim() + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt new file mode 100644 index 00000000..b6ba5a14 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt @@ -0,0 +1,163 @@ +/* + * 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 kotlinx.coroutines.* +import kotlin.time.Duration.Companion.milliseconds + +/** + * Monitors the system clipboard for OpenShift tokens and notifies listeners when detected. + * + * This class polls the clipboard at regular intervals and detects when a valid OpenShift token + * (format: sha256~[base64-like-chars]) appears. When a new token is detected, registered listeners + * are notified. + * + * ## Usage + * ```kotlin + * val monitor = ClipboardTokenMonitor() + * monitor.addListener { token -> + * println("Token detected: $token") + * } + * monitor.start() + * // ... later ... + * monitor.stop() + * ``` + * + * @param pollingIntervalMs Interval between clipboard checks in milliseconds. Defaults to 500ms. + * @param clipboardReader Reader for accessing clipboard content. Defaults to system clipboard. + */ +class ClipboardTokenMonitor( + private val pollingIntervalMs: Long = 500, + private val clipboardReader: ClipboardReader = SystemClipboardReader() +) { + + companion object { + private val OPENSHIFT_TOKEN_REGEX = Regex("^sha256~[A-Za-z0-9_-]{20,}$") + + /** + * Checks if a string is a valid OpenShift token. + * Format: sha256~[base64-like-characters with at least 20 chars] + * + * @param token The string to check + * @return true if the token matches OpenShift token format, false otherwise + */ + fun isOpenShiftToken(token: String?): Boolean { + if (token == null) return false + return OPENSHIFT_TOKEN_REGEX.matches(token.trim()) + } + } + + private val listeners = mutableListOf() + private var lastClipboardValue: String? = null + private var pollingJob: Job? = null + + /** + * Listener interface for token detection events. + */ + fun interface TokenDetectedListener { + /** + * Called when a new OpenShift token is detected in the clipboard. + * + * @param token The detected token string + */ + fun onTokenDetected(token: String) + } + + /** + * Adds a listener to be notified when tokens are detected. + * + * @param listener The listener to add + */ + fun addListener(listener: TokenDetectedListener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + /** + * Removes a previously registered listener. + * + * @param listener The listener to remove + */ + fun removeListener(listener: TokenDetectedListener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + /** + * Starts monitoring the clipboard for tokens. + * Polling runs on a background coroutine. + */ + fun start() { + if (pollingJob?.isActive == true) { + return + } + + this.pollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + val value = readClipboardText() + + if (value != null && value != lastClipboardValue) { + lastClipboardValue = value + + if (isOpenShiftToken(value)) { + notifyListeners(value) + } + } + + delay(pollingIntervalMs.milliseconds) + } + } + } + + /** + * Stops monitoring the clipboard. + */ + fun stop() { + pollingJob?.cancel() + pollingJob = null + } + + /** + * Checks the clipboard immediately for a token without starting continuous polling. + * + * @return The token if one is found, null otherwise + */ + fun checkNow(): String? { + val token = readClipboardText() + return if (isOpenShiftToken(token)) token else null + } + + /** + * Reads the current text content from the clipboard. + * + * @return The clipboard text or null if empty or not text + */ + private fun readClipboardText(): String? { + return clipboardReader.readText() + } + + /** + * Notifies all registered listeners of a detected token. + */ + private fun notifyListeners(token: String) { + val listenersCopy = synchronized(listeners) { listeners.toList() } + listenersCopy.forEach { listener -> + try { + listener.onTokenDetected(token) + } catch (_: Exception) { + // Ignore listener exceptions + } + } + } +} 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 3fe77369..0cffaf6a 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 @@ -49,6 +49,7 @@ import com.redhat.devtools.gateway.openshift.Cluster import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.settings.DevSpacesSettings +import com.redhat.devtools.gateway.util.ClipboardTokenMonitor import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.FilteringComboBox @@ -58,8 +59,6 @@ import io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.* import java.awt.Cursor import java.awt.Font -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor import java.awt.event.* import java.nio.file.Paths import javax.swing.JComponent @@ -117,72 +116,18 @@ class DevSpacesServerStepView( ApplicationManager.getApplication() .getService(RedHatAuthSessionManager::class.java) - private var lastClipboardValue: String? = null - private var clipboardPollingJob: Job? = null + private val clipboardMonitor = ClipboardTokenMonitor() - fun startClipboardPolling() { - clipboardPollingJob = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - val value = readClipboardText() + private var lastDetectedToken: String? = null - if (value != null && value != lastClipboardValue) { - lastClipboardValue = value - - suggestToken(value) - } - - delay(500) - } - } - } - - fun readClipboardText(): String? { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - val contents = clipboard.getContents(null) ?: return null - - return try { - if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { - contents.getTransferData(DataFlavor.stringFlavor) as? String - } else { - null - } - } catch (_: Exception) { - null - }?.trim() - } - - fun stopClipboardPolling() { - clipboardPollingJob?.cancel() - clipboardPollingJob = null - } - - private val OPENSHIFT_TOKEN_REGEX = - Regex("^sha256~[A-Za-z0-9_-]{20,}$") - - fun String?.isOpenShiftToken(): Boolean = - this?.let { OPENSHIFT_TOKEN_REGEX.matches(it.trim()) } == true - - private fun checkClipboardForToken() { - val token = readClipboardText() - if (token.isOpenShiftToken()) { - suggestToken(token) - } - } - - private fun suggestToken(token: String?) { + private fun suggestToken(token: String) { + lastDetectedToken = token ApplicationManager.getApplication().invokeLater ( { - if (token.isOpenShiftToken()) { - tokenSuggestionLabel.apply { - text = "Token detected in clipboard. Click here to use it." - isVisible = true - isEnabled = true - } - } else { - tokenSuggestionLabel.apply { - isVisible = false - isEnabled = false - } + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true } }, ModalityState.stateForComponent(component) @@ -201,9 +146,13 @@ class DevSpacesServerStepView( private var tokenLabelListener: MouseAdapter? = null private fun setupTokenSuggestionLabel() { + clipboardMonitor.addListener { token -> + suggestToken(token) + } + tokenLabelListener = object : MouseAdapter() { override fun mouseClicked(e: MouseEvent?) { - val token = lastClipboardValue ?: return + val token = lastDetectedToken ?: return tfToken.text = token tokenSuggestionLabel.isVisible = false } @@ -435,8 +384,8 @@ class DevSpacesServerStepView( startKubeconfigMonitor() updateAuthUiState() setupTokenSuggestionLabel() - startClipboardPolling() - checkClipboardForToken() + clipboardMonitor.start() + clipboardMonitor.checkNow()?.let { suggestToken(it) } showTokenCheckbox.addActionListener { tfToken.echoChar = if (showTokenCheckbox.isSelected) 0.toChar() else '•' @@ -451,7 +400,7 @@ class DevSpacesServerStepView( tokenLabelListener?.let { tokenSuggestionLabel.removeMouseListener(it) } - stopClipboardPolling() + clipboardMonitor.stop() stopKubeconfigMonitor() super.onDispose() } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt new file mode 100644 index 00000000..5eb19ced --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt @@ -0,0 +1,489 @@ +/* + * 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 kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +class ClipboardTokenMonitorTest { + + private lateinit var monitor: ClipboardTokenMonitor + private lateinit var mockClipboard: MockClipboardReader + private val detectedTokens = mutableListOf() + + @BeforeEach + fun beforeEach() { + mockClipboard = MockClipboardReader() + monitor = ClipboardTokenMonitor( + pollingIntervalMs = 100, + clipboardReader = mockClipboard + ) + detectedTokens.clear() + } + + @AfterEach + fun afterEach() { + monitor.stop() + } + + /** + * Mock clipboard reader for testing without requiring a display server + */ + private class MockClipboardReader : ClipboardReader { + private var content: String? = null + + override fun readText(): String? = content + + fun setContent(text: String?) { + content = text + } + } + + @Test + fun `#isOpenShiftToken returns true for valid token format`() { + // given + val validToken = "sha256~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(validToken) + + // then + assertThat(result).isTrue + } + + @Test + fun `#isOpenShiftToken returns true for token with underscores and dashes`() { + // given + val validToken = "sha256~ABC_DEF-GHI_JKL-MNO_PQR" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(validToken) + + // then + assertThat(result).isTrue + } + + @Test + fun `#isOpenShiftToken returns false for token without prefix`() { + // given + val invalidToken = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token with wrong prefix`() { + // given + val invalidToken = "sha512~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token too short`() { + // given + val invalidToken = "sha256~ABC" // Less than 20 chars after prefix + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token with invalid characters`() { + // given + val invalidToken = "sha256~ABCDEFGHIJKLMNOPQRST@#$%" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for null token`() { + // given + val nullToken: String? = null + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(nullToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for empty token`() { + // given + val emptyToken = "" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(emptyToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken trims whitespace before validation`() { + // given + val tokenWithWhitespace = " sha256~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop " + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(tokenWithWhitespace) + + // then + assertThat(result).isTrue + } + + @Test + fun `#checkNow returns token when valid token is in clipboard`() { + runBlocking { + // given + val validToken = "sha256~ValidTokenWith20PlusCharacters" + setClipboardContent(validToken) + delay(50.milliseconds) // Give clipboard time to update + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isEqualTo(validToken) + } + } + + @Test + fun `#checkNow returns null when invalid token is in clipboard`() { + runBlocking { + // given + setClipboardContent("not a valid token") + delay(50.milliseconds) + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isNull() + } + } + + @Test + fun `#checkNow returns null when clipboard is empty`() { + runBlocking { + // given + setClipboardContent("") + delay(50.milliseconds) + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isNull() + } + } + + @Test + fun `#checkNow does not interfere with monitoring`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val monitoringToken = "sha256~MonitoringToken123456789" + val checkNowToken = "sha256~CheckNowToken1234567890" + + // when + monitor.start() + setClipboardContent(checkNowToken) + val immediateResult = monitor.checkNow() + delay(100.milliseconds) + setClipboardAndWaitForDetection(monitoringToken) + + // then + assertThat(immediateResult).isEqualTo(checkNowToken) + assertThat(detectedTokens).contains(checkNowToken, monitoringToken) + } + } + + @Test + fun `#addListener registers listener successfully`() { + // given + val listener = ClipboardTokenMonitor.TokenDetectedListener { token -> + detectedTokens.add(token) + } + + // when + monitor.addListener(listener) + + // then - no exception thrown, listener is registered + assertThat(detectedTokens).isEmpty() + } + + @Test + fun `#removeListener removes listener successfully`() { + // given + val listener = ClipboardTokenMonitor.TokenDetectedListener { token -> + detectedTokens.add(token) + } + monitor.addListener(listener) + + // when + monitor.removeListener(listener) + + // then - no exception thrown, listener is removed + assertThat(detectedTokens).isEmpty() + } + + @Test + fun `#start begins monitoring clipboard`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val validToken = "sha256~MonitoringTestToken12345678" + + // when + monitor.start() + delay(150.milliseconds) // Wait less than one poll interval + setClipboardAndWaitForDetection(validToken) + + // then + assertThat(detectedTokens).contains(validToken) + } + } + + @Test + fun `#start does not start multiple polling jobs`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + + // when + monitor.start() + monitor.start() // Start again + monitor.start() // And again + + val validToken = "sha256~MultipleStartTestToken123" + setClipboardAndWaitForDetection(validToken) + + // then - should only be notified once per token change + assertThat(detectedTokens.filter { it == validToken }).hasSize(1) + } + } + + @Test + fun `#stop halts monitoring`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + monitor.start() + delay(100.milliseconds) + + // when + monitor.stop() + val validToken = "sha256~AfterStopTestToken1234567" + setClipboardAndWaitForDetection(validToken) + + // then - no tokens should be detected after stop + assertThat(detectedTokens).doesNotContain(validToken) + } + } + + @Test + fun `listener notified only once per unique token`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val validToken = "sha256~UniqueTokenTest123456789" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + // Don't change clipboard content + delay(250.milliseconds) + + // then - should only be notified once + assertThat(detectedTokens.filter { it == validToken }).hasSize(1) + } + } + + @Test + fun `listener notified for each different token`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val token1 = "sha256~FirstTokenTest1234567890" + val token2 = "sha256~SecondTokenTest123456789" + + // when + monitor.start() + setClipboardAndWaitForDetection(token1) + setClipboardAndWaitForDetection(token2) + + // then - should be notified for both + assertThat(detectedTokens).contains(token1, token2) + } + } + + @Test + fun `multiple listeners all receive notifications`() { + runBlocking { + // given + val tokens1 = mutableListOf() + val tokens2 = mutableListOf() + monitor.addListener { token -> tokens1.add(token) } + monitor.addListener { token -> tokens2.add(token) } + val validToken = "sha256~MultiListenerTest12345678" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + + // then - both listeners should receive the token + assertThat(tokens1).contains(validToken) + assertThat(tokens2).contains(validToken) + } + } + + @Test + fun `listener exception does not break other listeners`() { + runBlocking { + // given + val tokens = mutableListOf() + monitor.addListener { throw RuntimeException("Listener failure") } + monitor.addListener { token -> tokens.add(token) } + val validToken = "sha256~ExceptionTestToken1234567" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + + // then - second listener should still receive notification + assertThat(tokens).contains(validToken) + } + } + + @Test + fun `monitor ignores non-token clipboard content`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + + // when + monitor.start() + setClipboardContent("Just some regular text") + delay(250.milliseconds) + setClipboardContent("12345") + delay(250.milliseconds) + setClipboardContent("sha256~short") // Too short + delay(250.milliseconds) + + // then - no tokens should be detected + assertThat(detectedTokens).isEmpty() + } + } + + @Test + fun `monitor handles rapid clipboard changes`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val tokens = (1..5).map { "sha256~RapidChangeToken${it}234567890" } + + // when + monitor.start() + tokens.forEach { token -> + setClipboardContent(token) + delay(150.milliseconds) // Faster than polling interval + } + delay(300.milliseconds) // Let polling catch up + + // then - should detect at least some tokens (may not catch all due to rapid changes) + assertThat(detectedTokens).isNotEmpty() + tokens.forEach { token -> + if (detectedTokens.contains(token)) { + assertThat(detectedTokens.filter { it == token }).hasSize(1) + } + } + } + } + + @Test + fun `#stop can be called multiple times safely`() { + // when + monitor.stop() + monitor.stop() + monitor.stop() + + // then - no exception thrown + assertThat(true).isTrue + } + + @Test + fun `monitor can be restarted after stop`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val token1 = "sha256~FirstRunToken1234567890" + val token2 = "sha256~SecondRunToken123456789" + + // when - first run + startSetAndStop(token1) + + // when - second run + monitor.start() + setClipboardAndWaitForDetection(token2) + + // then - both tokens should be detected + assertThat(detectedTokens).contains(token1, token2) + } + } + + /** + * Helper to set clipboard content for testing + */ + private fun setClipboardContent(text: String) { + mockClipboard.setContent(text) + } + + /** + * Helper to set clipboard content and wait for detection + */ + private suspend fun setClipboardAndWaitForDetection(content: String, delayMs: Long = 250) { + setClipboardContent(content) + delay(delayMs.milliseconds) + } + + /** + * Helper to start monitor, set clipboard content, wait for detection, and stop + */ + private suspend fun startSetAndStop(content: String, detectionDelayMs: Long = 250, stopDelayMs: Long = 100) { + monitor.start() + setClipboardContent(content) + delay(detectionDelayMs.milliseconds) + monitor.stop() + delay(stopDelayMs.milliseconds) + } +} From 97eedd70c1e64eac14f421a5f85508a48be6dd3a Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Fri, 27 Mar 2026 21:56:36 +0100 Subject: [PATCH 14/32] have 'Authentication:' label/cell y-aligned to the top --- .../view/steps/DevSpacesServerStepView.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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 0cffaf6a..99aa000e 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 @@ -25,12 +25,11 @@ import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.platform.util.progress.reportRawProgress import com.intellij.ui.JBColor import com.intellij.ui.components.* -import com.intellij.ui.dsl.builder.Align -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.AlignY -import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.* +import com.intellij.ui.dsl.gridLayout.UnscaledGaps import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.code.AuthTokenKind @@ -63,6 +62,7 @@ import java.awt.event.* import java.nio.file.Paths import javax.swing.JComponent import javax.swing.JTextField +import javax.swing.UIManager import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener @@ -353,9 +353,18 @@ class DevSpacesServerStepView( row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority")) { cell(tfCertAuthorityData).align(Align.FILL) } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) { - cell(authTabs).align(Align.FILL) - } + val tabInsets = UIManager.getInsets("TabbedPane.tabInsets") ?: JBUI.insets(0) + + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) + .align(AlignY.TOP) + // This is the standard "nudge" to align a label with + // the text baseline of a adjacent component. + .customize(UnscaledGaps(top = JBUI.scale(16))) + + cell(authTabs) + .align(AlignX.FILL + AlignY.TOP) + }.layout(RowLayout.LABEL_ALIGNED) }.apply { isOpaque = false background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() From 317b6b4e267d45d1d1c787751f615f9217356ee4 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 30 Mar 2026 17:33:42 +0200 Subject: [PATCH 15/32] created classes for the different strategies --- .../view/steps/DevSpacesServerStepView.kt | 561 ++++-------------- .../auth/AbstractAuthenticationStrategy.kt | 53 ++ .../gateway/view/steps/auth/AuthMethod.kt | 23 + .../view/steps/auth/AuthenticationStrategy.kt | 68 +++ ...ClientCertificateAuthenticationStrategy.kt | 96 +++ ...nShiftCredentialsAuthenticationStrategy.kt | 135 +++++ .../OpenShiftOAuthAuthenticationStrategy.kt | 108 ++++ .../auth/RedHatSSOAuthenticationStrategy.kt | 108 ++++ .../steps/auth/TokenAuthenticationStrategy.kt | 176 ++++++ 9 files changed, 883 insertions(+), 445 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt 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 99aa000e..b52f3c8d 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 @@ -11,7 +11,6 @@ */ package com.redhat.devtools.gateway.view.steps -import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.PathManager @@ -23,20 +22,15 @@ import com.intellij.platform.ide.progress.ModalTaskOwner import com.intellij.platform.ide.progress.runWithModalProgressBlocking import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.platform.util.progress.reportRawProgress -import com.intellij.ui.JBColor -import com.intellij.ui.components.* +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* import com.intellij.ui.dsl.gridLayout.UnscaledGaps import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext -import com.redhat.devtools.gateway.auth.code.AuthTokenKind -import com.redhat.devtools.gateway.auth.code.TokenModel -import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider -import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS -import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.* import com.redhat.devtools.gateway.auth.tls.ui.UiTlsDecisionAdapter @@ -45,19 +39,14 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate 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.Projects import com.redhat.devtools.gateway.settings.DevSpacesSettings -import com.redhat.devtools.gateway.util.ClipboardTokenMonitor import com.redhat.devtools.gateway.util.isCancellationException +import com.redhat.devtools.gateway.view.steps.auth.* import com.redhat.devtools.gateway.view.ui.Dialogs 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 io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.* -import java.awt.Cursor -import java.awt.Font import java.awt.event.* import java.nio.file.Paths import javax.swing.JComponent @@ -66,9 +55,6 @@ import javax.swing.UIManager import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener -private const val SAVE_REQUIRES_TOKEN_DIFF = - "devspaces.save.requires.token.diff" - class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, private val enableNextButton: (() -> Unit)?, @@ -83,130 +69,28 @@ class DevSpacesServerStepView( private lateinit var kubeconfigMonitor: KubeConfigMonitor private var saveToKubeconfig: Boolean = false - private val saveKubeconfigCheckboxes = mutableListOf() - - private fun syncSaveKubeconfigCheckboxes(source: JBCheckBox) { - saveKubeconfigCheckboxes - .filter { it !== source } - .forEach { it.isSelected = saveToKubeconfig } - } - - private fun createSaveKubeconfigCheckbox( - requiresTokenDiff: Boolean? = false - ): JBCheckBox = - JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")).apply { - isOpaque = false - background = null - - isSelected = saveToKubeconfig - - putClientProperty(SAVE_REQUIRES_TOKEN_DIFF, requiresTokenDiff) - - addActionListener { - saveToKubeconfig = isSelected - syncSaveKubeconfigCheckboxes(this) - } - saveKubeconfigCheckboxes += this + private val saveKubeconfigCheckbox = JBCheckBox( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration") + ).apply { + isOpaque = false + background = null + isSelected = saveToKubeconfig + addActionListener { + saveToKubeconfig = isSelected } - - private val updateKubeconfigCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")) + } private val sessionManager = ApplicationManager.getApplication() .getService(RedHatAuthSessionManager::class.java) - private val clipboardMonitor = ClipboardTokenMonitor() - - private var lastDetectedToken: String? = null - - private fun suggestToken(token: String) { - lastDetectedToken = token - ApplicationManager.getApplication().invokeLater ( - { - tokenSuggestionLabel.apply { - text = "Token detected in clipboard. Click here to use it." - isVisible = true - isEnabled = true - } - }, - ModalityState.stateForComponent(component) - ) - } - - private val tokenSuggestionLabel = JBLabel() - .apply { - text = "" - foreground = JBColor.BLUE - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - isVisible = false - font = font.deriveFont(Font.ITALIC or Font.PLAIN) - } - - private var tokenLabelListener: MouseAdapter? = null - - private fun setupTokenSuggestionLabel() { - clipboardMonitor.addListener { token -> - suggestToken(token) - } - - tokenLabelListener = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent?) { - val token = lastDetectedToken ?: return - tfToken.text = token - tokenSuggestionLabel.isVisible = false - } - } - tokenSuggestionLabel.addMouseListener(tokenLabelListener) - tokenSuggestionLabel.isVisible = false - } - - private var tfToken = JBPasswordField() - .apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - addKeyListener(createEnterKeyListener()) - } - private var tfCertAuthorityData = JBTextField() .apply { document.addDocumentListener(onFieldChanged()) PasteClipboardMenu.addTo(this) } - private val tfUsername = JBTextField() - .apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - addKeyListener(createEnterKeyListener()) - } - private val tfPassword = JBPasswordField() - .apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - addKeyListener(createEnterKeyListener()) - } - private val tfClientCert = JBTextField() - .apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - } - private val tfClientKey = JBTextField() - .apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - } - private val showTokenCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token")) - .apply { - isOpaque = false - background = null - } - private val showPasswordCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_password")) - .apply { - isOpaque = false - background = null - } - private var tfServer = FilteringComboBox.create( { it?.toString() ?: "" }, @@ -214,95 +98,69 @@ class DevSpacesServerStepView( ) .apply { addItemListener(::onClusterSelected) - PasteClipboardMenu.addTo(this.editor.editorComponent as JTextField) - (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) + val editor = this.editor.editorComponent as JTextField + PasteClipboardMenu.addTo(editor) + editor.addKeyListener(createEnterKeyListener()) + } + + private val authStrategies: List by lazy { + val tokenStrategy = TokenAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfig, + ::onFieldChanged, + ::createEnterKeyListener + ) + + val setTokenDisplay: suspend (String) -> Unit = { token -> + withContext(Dispatchers.Main) { + tokenStrategy.tfToken.text = token + } } - private enum class AuthMethod { - TOKEN, // User token - CLIENT_CERTIFICATE, // Client certificate - OPENSHIFT, // browser PKCE - OPENSHIFT_CREDENTIALS, // username/password - REDHAT_SSO // RH SSO (Sandbox) + listOf( + tokenStrategy, + ClientCertificateAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfig, + ::onFieldChanged + ), + OpenShiftOAuthAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfig, + setTokenDisplay + ), + OpenShiftCredentialsAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfig, + ::onFieldChanged, + ::createEnterKeyListener, + setTokenDisplay + ), + RedHatSSOAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfig, + sessionManager + ) + ) } - private var authMethod: AuthMethod = AuthMethod.TOKEN + private var currentStrategy: AuthenticationStrategy? = null + get() = field ?: authStrategies.firstOrNull().also { field = it } - - private fun updateAuthUiState() { - enableNextButton?.invoke() - } + private inline fun findStrategy(): T? = + authStrategies.firstOrNull { it is T } as? T private fun getCurrentAuthTokenValue(): CharArray? = - when (authMethod) { - AuthMethod.TOKEN -> tfToken.password + when (currentStrategy?.getAuthMethod()) { + AuthMethod.TOKEN -> (currentStrategy as? TokenAuthenticationStrategy)?.tfToken?.password else -> null // other tabs don't have a token yet } - private fun tokenPanel() = panel { - row { - cell(tokenSuggestionLabel).align(Align.FILL) - } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { - cell(tfToken).align(Align.FILL) - } - row { - cell(showTokenCheckbox) - } - row { - cell(createSaveKubeconfigCheckbox(true).also { saveKubeconfigCheckboxes += it }) - } - } - - private fun clientCertificatePanel() = panel { - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_certificate")) { - cell(tfClientCert).align(Align.FILL) - } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_key")) { - cell(tfClientKey).align(Align.FILL) - } - row { - cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) - } - } - - private fun openShiftOAuthPanel() = panel { - row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.openshift_oauth_info")) - } - row { - cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) - } - } - - private fun credentialsPanel() = panel { - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.username")) { - cell(tfUsername).align(Align.FILL) - } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.password")) { - cell(tfPassword).align(Align.FILL) - } - row { - cell(showPasswordCheckbox) - } - row { - cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) - } - } - - private fun redHatSSOPanel() = panel { - row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_info")) - } - row { - label( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_token_note") - ).comment( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.pipeline_token_comment") - ) - } - } - private fun tabPanel(p: JComponent): JComponent = p.apply { isOpaque = false @@ -313,34 +171,16 @@ class DevSpacesServerStepView( isOpaque = false background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - addTab( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.token"), - tabPanel(tokenPanel())) - addTab( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.client_certificate"), - tabPanel(clientCertificatePanel()) - ) - addTab( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.openshift_oauth"), - tabPanel(openShiftOAuthPanel())) - addTab( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.credentials"), - tabPanel(credentialsPanel())) - addTab( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.redhat_sso"), - tabPanel(redHatSSOPanel())) + // Add tabs for each strategy + authStrategies.forEach { strategy -> + addTab(strategy.getTabTitle(), tabPanel(strategy.createPanel())) + } addChangeListener { - authMethod = when (selectedIndex) { - 0 -> AuthMethod.TOKEN - 1 -> AuthMethod.CLIENT_CERTIFICATE - 2 -> AuthMethod.OPENSHIFT - 3 -> AuthMethod.OPENSHIFT_CREDENTIALS - else -> AuthMethod.REDHAT_SSO - } + currentStrategy = authStrategies.getOrNull(selectedIndex) - updateKubeconfigCheckbox.isVisible = - authMethod != AuthMethod.REDHAT_SSO + saveKubeconfigCheckbox.isVisible = + currentStrategy?.getAuthMethod() != AuthMethod.REDHAT_SSO enableNextButton?.invoke() } @@ -365,6 +205,9 @@ class DevSpacesServerStepView( cell(authTabs) .align(AlignX.FILL + AlignY.TOP) }.layout(RowLayout.LABEL_ALIGNED) + row { + cell(saveKubeconfigCheckbox) + } }.apply { isOpaque = false background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() @@ -391,25 +234,14 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() - updateAuthUiState() - setupTokenSuggestionLabel() - clipboardMonitor.start() - clipboardMonitor.checkNow()?.let { suggestToken(it) } - - showTokenCheckbox.addActionListener { - tfToken.echoChar = if (showTokenCheckbox.isSelected) 0.toChar() else '•' - } + enableNextButton?.invoke() - showPasswordCheckbox.addActionListener { - tfPassword.echoChar = if (showPasswordCheckbox.isSelected) 0.toChar() else '•' - } + findStrategy()?.startMonitoring(component) } override fun onDispose() { - tokenLabelListener?.let { - tokenSuggestionLabel.removeMouseListener(it) - } - clipboardMonitor.stop() + findStrategy()?.stopMonitoring() + stopKubeconfigMonitor() super.onDispose() } @@ -419,10 +251,14 @@ class DevSpacesServerStepView( (event.item as? Cluster)?.let { selectedCluster -> if (allClusters.contains(selectedCluster)) { tfCertAuthorityData.text = selectedCluster.certificateAuthorityData - tfToken.text = selectedCluster.token - tfClientCert.text = selectedCluster.clientCertData - tfClientKey.text = selectedCluster.clientKeyData - updateKubeconfigCheckbox.isSelected = false + findStrategy()?.tfToken?.apply { + text = selectedCluster.token + } + findStrategy()?.apply { + tfClientCert.text = selectedCluster.clientCertData + tfClientKey.text = selectedCluster.clientKeyData + } + saveKubeconfigCheckbox.isSelected = false } } } @@ -455,15 +291,13 @@ class DevSpacesServerStepView( && currentToken?.isNotEmpty() == true && !cluster.token.toCharArray().contentEquals(currentToken) - saveKubeconfigCheckboxes.forEach { checkbox -> - val requiresTokenDiff = - checkbox.getClientProperty(SAVE_REQUIRES_TOKEN_DIFF) as? Boolean ?: false + // Only TokenAuthenticationStrategy requires token diff to enable save + val requiresTokenDiff = currentStrategy is TokenAuthenticationStrategy - checkbox.isEnabled = - !allClusters.contains(cluster) - || !requiresTokenDiff - || tokenChanged - } + saveKubeconfigCheckbox.isEnabled = + !allClusters.contains(cluster) + || !requiresTokenDiff + || tokenChanged } @@ -506,6 +340,7 @@ class DevSpacesServerStepView( override fun onNext(): Boolean { val selectedCluster = tfServer.selectedItem as? Cluster ?: return false val server = selectedCluster.url + val strategy = currentStrategy ?: return false var success = false if (!confirmAuthSwitchIfNeeded()) return false @@ -519,156 +354,16 @@ class DevSpacesServerStepView( reporter.text("Connecting to cluster...") val tlsContext = resolveSslContext(server) - val certAuthorityData = tfCertAuthorityData.text - when (authMethod) { - AuthMethod.TOKEN -> { - reporter.text("Validating token...") - - val token = String(tfToken.password) - - val client = createValidatedApiClient( - server, certAuthorityData, - token, null, null, tlsContext, - "Authentication failed: invalid server URL or token." - ) - - saveKubeconfig(selectedCluster, token, reporter) - devSpacesContext.client = client - } - - AuthMethod.CLIENT_CERTIFICATE -> { - reporter.text("Validating client certificate...") - - val clientCertPem = tfClientCert.text - val clientKeyPem = tfClientKey.text - - val client = createValidatedApiClient( - server, certAuthorityData, - null, clientCertPem, clientKeyPem, tlsContext, - "Authentication failed: invalid server URL or token." - ) - - require(Projects(client).isAuthenticated()) { - "Authentication failed: invalid client certificate or key." - } - - saveKubeconfig(selectedCluster, clientCertPem, clientKeyPem, reporter) - devSpacesContext.client = client - } - - AuthMethod.OPENSHIFT_CREDENTIALS -> { - reporter.text("Authenticating with OpenShift credentials...") - - val username = tfUsername.text - val password = String(tfPassword.password) - - val sessionManager = OpenShiftAuthSessionManager() - - val osToken = sessionManager.loginWithCredentials( - apiServerUrl = selectedCluster.url, - username = username, - password = password, - tlsContext.sslContext - ) - - val finalToken = TokenModel( - accessToken = osToken.accessToken, - expiresAt = osToken.expiresAt, - accountLabel = osToken.accountLabel, - kind = AuthTokenKind.TOKEN, - clusterApiUrl = selectedCluster.url - ) - - reporter.text("Validating cluster access...") - - val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: invalid OpenShift credentials." - ) - - withContext(Dispatchers.Main) { - tfToken.text = finalToken.accessToken - } - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) - devSpacesContext.client = client - } - - AuthMethod.OPENSHIFT -> { - reporter.text("Authenticating with Openshift...") - - val openshiftSSessionManager = OpenShiftAuthSessionManager() - val uri = openshiftSSessionManager.startLogin( - selectedCluster.url, - tlsContext.sslContext - ) - withContext(Dispatchers.Main) { - BrowserUtil.browse(uri) - } - - reporter.text("Waiting for you to complete login in your browser...") - ensureActive() - - reporter.text("Obtaining OpenShift access...") - val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - - val finalToken = TokenModel( - accessToken = osToken.accessToken, - expiresAt = osToken.expiresAt, - accountLabel = osToken.accountLabel, - kind = AuthTokenKind.TOKEN, - clusterApiUrl = selectedCluster.url - ) - - reporter.text("Validating cluster access...") - - val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: token received from OpenShift Authenticator is invalid or expired." - ) - - withContext(Dispatchers.Main) { - tfToken.text = finalToken.accessToken - } - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) - devSpacesContext.client = client - } - - AuthMethod.REDHAT_SSO -> { - reporter.text("Authenticating with Red Hat...") - - val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) - withContext(Dispatchers.Main) { - BrowserUtil.browse(uri) - } - - reporter.text("Waiting for you to complete login in your browser...") - ensureActive() - - val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - reporter.text("Obtaining OpenShift access...") - - val sandboxAuth = SandboxClusterAuthProvider() - val finalToken = sandboxAuth.authenticate(ssoToken) - - reporter.text("Validating cluster access...") - - val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." - ) - - // Do not save SSO tokens - if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) - } - devSpacesContext.client = client - } - } + strategy.authenticate( + selectedCluster, + server, + certAuthorityData, + tlsContext, + reporter, + devSpacesContext + ) } } } @@ -690,10 +385,12 @@ class DevSpacesServerStepView( } private fun confirmAuthSwitchIfNeeded(): Boolean { - val tokenPresent = tfToken.password.isNotEmpty() - val certPresent = tfClientCert.text.isNotBlank() || tfClientKey.text.isNotBlank() + val tokenPresent = findStrategy()?.tfToken?.password?.isNotEmpty() == true + val certStrategy = findStrategy() + val certPresent = certStrategy?.tfClientCert?.text?.isNotBlank() == true + || certStrategy?.tfClientKey?.text?.isNotBlank() == true - val (message, shouldAsk) = when (authMethod) { + val (message, shouldAsk) = when (currentStrategy?.getAuthMethod()) { AuthMethod.TOKEN -> { if (certPresent) { "Switching to token authentication will remove the configured client certificate. Continue?" to true @@ -721,38 +418,8 @@ class DevSpacesServerStepView( .ask(component) } - @Throws(IllegalArgumentException::class) - private fun createValidatedApiClient( - server: String, - certificateAuthorityData: String? = null, - token: String? = null, - clientCertPem: String? = null, - clientKeyPem: String? = null, - tlsContext: TlsContext, - errorMessage: String? = null - ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) - .create(server, certificateAuthorityData?.toCharArray(), token?.toCharArray(), - clientCertPem?.toCharArray(), clientKeyPem?.toCharArray(), tlsContext) - .also { client -> - require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } - } - override fun isNextEnabled(): Boolean = - when (authMethod) { - AuthMethod.TOKEN -> - tfToken.password?.isNotEmpty() == true - - AuthMethod.CLIENT_CERTIFICATE -> - tfClientCert.text.isNotBlank() && tfClientKey.text.isNotBlank() - - AuthMethod.OPENSHIFT_CREDENTIALS -> - tfUsername.text.isNotBlank() && - tfPassword.password?.isNotEmpty() == true - - AuthMethod.OPENSHIFT, - AuthMethod.REDHAT_SSO -> - tfServer.selectedItem != null - } + currentStrategy?.isNextEnabled() ?: false private val sessionTrustStore = SessionTlsTrustStore() private val persistentKeyStore = PersistentKeyStore( @@ -838,9 +505,13 @@ class DevSpacesServerStepView( ?: clusters.firstOrNull() tfServer.selectedItem = toSelect tfCertAuthorityData.text = toSelect?.certificateAuthorityData ?: "" - tfToken.text = toSelect?.token ?: "" - tfClientCert.text = toSelect?.clientCertData ?: "" - tfClientKey.text = toSelect?.clientKeyData ?: "" + findStrategy()?.tfToken?.apply { + text = toSelect?.token ?: "" + } + findStrategy()?.apply { + tfClientCert.text = toSelect?.clientCertData ?: "" + tfClientKey.text = toSelect?.clientKeyData ?: "" + } } private fun startKubeconfigMonitor() { 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 new file mode 100644 index 00000000..dfbe7691 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -0,0 +1,53 @@ +/* + * 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.view.steps.auth + +import com.intellij.platform.util.progress.RawProgressReporter +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.Projects +import io.kubernetes.client.openapi.ApiClient + +/** + * Abstract base class for authentication strategies. + * Provides common functionality and access to shared UI components. + */ +@Suppress("UnstableApiUsage") +abstract class AbstractAuthenticationStrategy( + protected val tfServer: Any, // FilteringComboBox + protected val saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + protected val saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit +) : AuthenticationStrategy { + + /** + * Creates a validated API client. + */ + @Throws(IllegalArgumentException::class) + protected fun createValidatedApiClient( + server: String, + certificateAuthorityData: String? = null, + token: String? = null, + clientCertPem: String? = null, + clientKeyPem: String? = null, + tlsContext: TlsContext, + errorMessage: String? = null + ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) + .create( + server, certificateAuthorityData?.toCharArray(), token?.toCharArray(), + clientCertPem?.toCharArray(), clientKeyPem?.toCharArray(), tlsContext + ) + .also { client -> + require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt new file mode 100644 index 00000000..5a8a365e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +/** + * Enumeration of supported authentication methods. + */ +enum class AuthMethod { + TOKEN, // User token + CLIENT_CERTIFICATE, // Client certificate + OPENSHIFT, // browser PKCE + OPENSHIFT_CREDENTIALS, // username/password + REDHAT_SSO // RH SSO (Sandbox) +} 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 new file mode 100644 index 00000000..a8f73b8e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt @@ -0,0 +1,68 @@ +/* + * 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.view.steps.auth + +import com.intellij.platform.util.progress.RawProgressReporter +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import io.kubernetes.client.openapi.ApiClient +import javax.swing.JPanel + +/** + * Interface for authentication strategies. + * Each strategy encapsulates the UI panel creation, authentication logic, + * and validation for a specific authentication method. + */ +interface AuthenticationStrategy { + + /** + * Returns the authentication method type for this strategy. + */ + fun getAuthMethod(): AuthMethod + + /** + * Returns the tab title for this authentication method. + */ + fun getTabTitle(): String + + /** + * Creates the UI panel for this authentication method. + */ + fun createPanel(): JPanel + + /** + * Performs the authentication process. + * + * @param selectedCluster The cluster to authenticate against + * @param server The server URL + * @param certAuthorityData The certificate authority data + * @param tlsContext The TLS context for secure connections + * @param reporter The progress reporter + * @param devSpacesContext The DevSpaces context to update + * @return true if authentication succeeded, false otherwise + */ + @Suppress("UnstableApiUsage") + suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) + + /** + * Determines if the "Next" button should be enabled for this authentication method. + */ + fun isNextEnabled(): Boolean +} 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 new file mode 100644 index 00000000..dd0126cd --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -0,0 +1,96 @@ +/* + * 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.view.steps.auth + +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import javax.swing.event.DocumentListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.openshift.Projects +import javax.swing.JPanel + +/** + * Authentication strategy for client certificate authentication. + */ +@Suppress("UnstableApiUsage") +class ClientCertificateAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val onFieldChanged: () -> DocumentListener +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig, + saveKubeconfigCert +) { + + val tfClientCert = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + + val tfClientKey = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + + override fun getAuthMethod(): AuthMethod = AuthMethod.CLIENT_CERTIFICATE + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.client_certificate") + + override fun createPanel(): JPanel = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_certificate")) { + cell(tfClientCert).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_key")) { + cell(tfClientKey).align(Align.FILL) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) { + reporter.text("Validating client certificate...") + + val clientCertPem = tfClientCert.text + val clientKeyPem = tfClientKey.text + + val client = createValidatedApiClient( + server, certAuthorityData, + null, clientCertPem, clientKeyPem, tlsContext, + "Authentication failed: invalid server URL or token." + ) + + require(Projects(client).isAuthenticated()) { + "Authentication failed: invalid client certificate or key." + } + + saveKubeconfigCert(selectedCluster, clientCertPem, clientKeyPem, reporter) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + tfClientCert.text.isNotBlank() && tfClientKey.text.isNotBlank() +} 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 new file mode 100644 index 00000000..54d1f05a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -0,0 +1,135 @@ +/* + * 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.view.steps.auth + +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import javax.swing.event.DocumentListener +import java.awt.event.KeyListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for OpenShift credentials (username/password). + */ +@Suppress("UnstableApiUsage") +class OpenShiftCredentialsAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val onFieldChanged: () -> DocumentListener, + private val createEnterKeyListener: () -> KeyListener, + private val setTokenDisplay: suspend (String) -> Unit +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig, + saveKubeconfigCert +) { + + private val tfUsername = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + private val tfPassword = JBPasswordField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + private val showPasswordCheckbox = JBCheckBox( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_password") + ).apply { + isOpaque = false + background = null + addActionListener { + tfPassword.echoChar = if (isSelected) 0.toChar() else '•' + } + } + + override fun getAuthMethod(): AuthMethod = AuthMethod.OPENSHIFT_CREDENTIALS + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.credentials") + + override fun createPanel(): JPanel = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.username")) { + cell(tfUsername).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.password")) { + cell(tfPassword).align(Align.FILL) + } + row { + cell(showPasswordCheckbox) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) { + reporter.text("Authenticating with OpenShift credentials...") + + val username = tfUsername.text + val password = String(tfPassword.password) + + val sessionManager = OpenShiftAuthSessionManager() + + val osToken = sessionManager.loginWithCredentials( + apiServerUrl = selectedCluster.url, + username = username, + password = password, + tlsContext.sslContext + ) + + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + + reporter.text("Validating cluster access...") + + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: invalid OpenShift credentials." + ) + + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + tfUsername.text.isNotBlank() && tfPassword.password?.isNotEmpty() == true +} 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 new file mode 100644 index 00000000..0ceb8e01 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -0,0 +1,108 @@ +/* + * 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.view.steps.auth + +import com.intellij.ide.BrowserUtil +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.panel +import kotlin.coroutines.coroutineContext +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for OpenShift OAuth (browser-based PKCE). + */ +@Suppress("UnstableApiUsage") +class OpenShiftOAuthAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val setTokenDisplay: suspend (String) -> Unit +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig, + saveKubeconfigCert +) { + + override fun getAuthMethod(): AuthMethod = AuthMethod.OPENSHIFT + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.openshift_oauth") + + override fun createPanel(): JPanel = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.openshift_oauth_info")) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) { + reporter.text("Authenticating with Openshift...") + + val openshiftSSessionManager = OpenShiftAuthSessionManager() + val uri = openshiftSSessionManager.startLogin( + selectedCluster.url, + tlsContext.sslContext + ) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } + + reporter.text("Waiting for you to complete login in your browser...") + currentCoroutineContext().ensureActive() + + reporter.text("Obtaining OpenShift access...") + val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + + reporter.text("Validating cluster access...") + + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired." + ) + + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null +} 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 new file mode 100644 index 00000000..4fe08b7f --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -0,0 +1,108 @@ +/* + * 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.view.steps.auth + +import com.intellij.ide.BrowserUtil +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.panel +import kotlin.coroutines.coroutineContext +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for Red Hat SSO (Sandbox). + */ +@Suppress("UnstableApiUsage") +class RedHatSSOAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val sessionManager: RedHatAuthSessionManager +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig, + saveKubeconfigCert +) { + + override fun getAuthMethod(): AuthMethod = AuthMethod.REDHAT_SSO + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.redhat_sso") + + override fun createPanel(): JPanel = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_info")) + } + row { + label( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_token_note") + ).comment( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.pipeline_token_comment") + ) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) { + reporter.text("Authenticating with Red Hat...") + + val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } + + reporter.text("Waiting for you to complete login in your browser...") + currentCoroutineContext().ensureActive() + + val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + reporter.text("Obtaining OpenShift access...") + + val sandboxAuth = SandboxClusterAuthProvider() + val finalToken = sandboxAuth.authenticate(ssoToken) + + reporter.text("Validating cluster access...") + + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) + + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + } + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null +} 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 new file mode 100644 index 00000000..fc877d2d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt @@ -0,0 +1,176 @@ +/* + * 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.view.steps.auth + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JCheckBox +import javax.swing.event.DocumentListener +import java.awt.event.KeyListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.util.ClipboardTokenMonitor +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Authentication strategy for token-based authentication. + */ +@Suppress("UnstableApiUsage") +class TokenAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val onFieldChanged: () -> DocumentListener, + private val createEnterKeyListener: () -> KeyListener +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig, + saveKubeconfigCert +) { + + val tfToken = JBPasswordField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + val tokenSuggestionLabel = JBLabel().apply { + text = "" + foreground = JBColor.BLUE + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = false + font = font.deriveFont(Font.ITALIC or Font.PLAIN) + } + + private val showTokenCheckbox = JBCheckBox( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token") + ).apply { + isOpaque = false + background = null + addActionListener { + tfToken.echoChar = if (isSelected) 0.toChar() else '•' + } + } + + private val clipboardMonitor = ClipboardTokenMonitor() + private var lastDetectedToken: String? = null + private var tokenLabelListener: MouseAdapter? = null + + override fun getAuthMethod(): AuthMethod = AuthMethod.TOKEN + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.token") + + override fun createPanel(): JPanel = panel { + row { + cell(tokenSuggestionLabel).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { + cell(tfToken).align(Align.FILL) + } + row { + cell(showTokenCheckbox) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + reporter: RawProgressReporter, + devSpacesContext: DevSpacesContext + ) { + reporter.text("Validating token...") + + val token = String(tfToken.password) + + val client = createValidatedApiClient( + server, certAuthorityData, + token, null, null, tlsContext, + "Authentication failed: invalid server URL or token." + ) + + saveKubeconfig.invoke(selectedCluster, token, reporter) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + tfToken.password?.isNotEmpty() == true + + /** + * Start monitoring clipboard for tokens. + * Should be called during initialization. + */ + fun startMonitoring(parentComponent: JComponent) { + clipboardMonitor.addListener { token -> + lastDetectedToken = token + ApplicationManager.getApplication().invokeLater( + { + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true + } + }, + ModalityState.stateForComponent(parentComponent) + ) + } + + tokenLabelListener = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + val token = lastDetectedToken ?: return + tfToken.text = token + tokenSuggestionLabel.isVisible = false + } + } + tokenSuggestionLabel.addMouseListener(tokenLabelListener) + tokenSuggestionLabel.isVisible = false + + clipboardMonitor.start() + clipboardMonitor.checkNow()?.let { token -> + lastDetectedToken = token + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true + } + } + } + + /** + * Stop monitoring clipboard and clean up resources. + * Should be called during disposal. + */ + fun stopMonitoring() { + tokenLabelListener?.let { listener -> + tokenSuggestionLabel.removeMouseListener(listener) + } + clipboardMonitor.stop() + } +} From ee512e1a1d80480e7d859d59e08908c489524d07 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 13 Apr 2026 17:09:45 +0200 Subject: [PATCH 16/32] 'Show token' checkbox is not to the right of the token textfield Signed-off-by: Andre Dietisheim --- .../gateway/view/steps/auth/TokenAuthenticationStrategy.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 fc877d2d..85d11691 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 @@ -91,9 +91,7 @@ class TokenAuthenticationStrategy( cell(tokenSuggestionLabel).align(Align.FILL) } row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { - cell(tfToken).align(Align.FILL) - } - row { + cell(tfToken).resizableColumn().align(Align.FILL) cell(showTokenCheckbox) } } From 1ede5ac9613d11036c0b67c7e20275bbae2afeec Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 27 Apr 2026 14:26:11 +0200 Subject: [PATCH 17/32] replaced 'show token'-checkbox by icon within password field Signed-off-by: Andre Dietisheim --- .../steps/auth/TokenAuthenticationStrategy.kt | 29 ++--- .../view/ui/PasswordFieldWithToggle.kt | 104 ++++++++++++++++++ 2 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt 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 85d11691..d1e0ca60 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 @@ -15,21 +15,18 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.ui.JBColor -import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBPasswordField -import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.panel import java.awt.Cursor import java.awt.Font import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.JCheckBox import javax.swing.event.DocumentListener import java.awt.event.KeyListener import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.view.ui.PasswordFieldWithToggle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster @@ -53,12 +50,15 @@ class TokenAuthenticationStrategy( saveKubeconfigCert ) { - val tfToken = JBPasswordField().apply { - document.addDocumentListener(onFieldChanged()) - PasteClipboardMenu.addTo(this) - addKeyListener(createEnterKeyListener()) + private val tokenFieldWithToggle = PasswordFieldWithToggle().apply { + setToggleButtonTooltip(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token")) + passwordField.document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(passwordField) + passwordField.addKeyListener(createEnterKeyListener()) } + val tfToken = tokenFieldWithToggle.passwordField + val tokenSuggestionLabel = JBLabel().apply { text = "" foreground = JBColor.BLUE @@ -67,16 +67,6 @@ class TokenAuthenticationStrategy( font = font.deriveFont(Font.ITALIC or Font.PLAIN) } - private val showTokenCheckbox = JBCheckBox( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token") - ).apply { - isOpaque = false - background = null - addActionListener { - tfToken.echoChar = if (isSelected) 0.toChar() else '•' - } - } - private val clipboardMonitor = ClipboardTokenMonitor() private var lastDetectedToken: String? = null private var tokenLabelListener: MouseAdapter? = null @@ -91,8 +81,7 @@ class TokenAuthenticationStrategy( cell(tokenSuggestionLabel).align(Align.FILL) } row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { - cell(tfToken).resizableColumn().align(Align.FILL) - cell(showTokenCheckbox) + cell(tokenFieldWithToggle).resizableColumn().align(Align.FILL) } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt new file mode 100644 index 00000000..4b903726 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.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.view.ui + +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBPasswordField +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.JButton +import javax.swing.JLayeredPane +import javax.swing.JPanel + +/** + * A password field with an integrated show/hide toggle button. + * The eye icon button appears inside the field on the right side. + */ +class PasswordFieldWithToggle : JPanel() { + + val passwordField = JBPasswordField().apply { + margin = JBUI.insets(0, 5, 0, 30) + } + + private val toggleButton = JButton().apply { + icon = AllIcons.General.InspectionsEye + toolTipText = "Show/Hide password" + isBorderPainted = false + isContentAreaFilled = false + isFocusPainted = false + isOpaque = false + margin = JBUI.emptyInsets() + size = Dimension(20, 20) + preferredSize = Dimension(20, 20) + minimumSize = Dimension(20, 20) + maximumSize = Dimension(20, 20) + addActionListener { + val isVisible = passwordField.echoChar == 0.toChar() + passwordField.echoChar = if (isVisible) '•' else 0.toChar() + passwordField.requestFocusInWindow() + } + } + + init { + layout = null + isOpaque = false + background = null + + // Add password field first + add(passwordField) + + // Add button directly to password field so it renders on top + passwordField.layout = null + passwordField.add(toggleButton) + } + + override fun paintComponent(g: java.awt.Graphics) { + // Don't paint background - stay transparent + } + + override fun doLayout() { + super.doLayout() + + passwordField.setBounds(0, 0, width, height) + + layoutToggle() + } + + private fun layoutToggle() { + // Position button inside password field on the right + val buttonWidth = 20 + val buttonHeight = 20 + val x = passwordField.width - buttonWidth - 5 + val y = (passwordField.height - buttonHeight) / 2 + toggleButton.setBounds(x, y, buttonWidth, buttonHeight) + } + + + override fun getPreferredSize(): Dimension { + return passwordField.preferredSize + } + + override fun getMinimumSize(): Dimension { + return passwordField.minimumSize + } + + override fun getMaximumSize(): Dimension { + return passwordField.maximumSize + } + + /** + * Set custom tooltip text for the toggle button. + */ + fun setToggleButtonTooltip(tooltip: String) { + toggleButton.toolTipText = tooltip + } +} From c60eb23e9a275878000fbf05b7a2e04c1294c888 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 11:32:34 +0200 Subject: [PATCH 18/32] cleanup Signed-off-by: Andre Dietisheim --- .../gateway/auth/code/RedHatAuthCodeFlow.kt | 58 ++++++++++--------- .../auth/session/RedHatAuthSessionManager.kt | 3 +- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt index bcf83728..6b7babe6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -43,18 +43,19 @@ import javax.net.ssl.SSLContext class RedHatAuthCodeFlow( private val clientId: String, private val redirectUri: URI, - private val providerMetadata: OIDCProviderMetadata, - private val sslContext: SSLContext + private val providerMetadata: OIDCProviderMetadata ) : AuthCodeFlow { private lateinit var codeVerifier: CodeVerifier private lateinit var nonce: Nonce private lateinit var state: State - private val httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() + private val httpClient by lazy { + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + } override suspend fun startAuthFlow(): AuthCodeRequest { codeVerifier = CodeVerifier() @@ -83,11 +84,6 @@ class RedHatAuthCodeFlow( override suspend fun handleCallback(parameters: Parameters): SSOToken { val code = parameters["code"] ?: error("Missing 'code' parameter in callback") - fun encodeForm(vararg pairs: Pair): String = - pairs.joinToString("&") { (k, v) -> - "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" - } - val form = encodeForm( "grant_type" to "authorization_code", "client_id" to clientId, @@ -120,20 +116,7 @@ class RedHatAuthCodeFlow( val idToken = body["id_token"]?.jsonPrimitive?.content.orEmpty() val expiresInSeconds = body["expires_in"]?.jsonPrimitive?.longOrNull ?: 3600 - val accountLabel = if (idToken.isNotBlank()) { - try { - val jwt = JWTParser.parse(idToken) as SignedJWT - val claims = jwt.jwtClaimsSet - - claims.getStringClaim("preferred_username") - ?: claims.getStringClaim("email") - ?: "unknown-user" - } catch (e: Exception) { - "unknown-user" - } - } else { - "unknown-user" - } + val accountLabel = createAccountLabel(idToken) return SSOToken( accessToken = accessToken, @@ -146,7 +129,28 @@ class RedHatAuthCodeFlow( override suspend fun login(parameters: Parameters): SSOToken = error( "Direct login is not supported for Red Hat SSO authentication. " + - "This flow requires browser-based authentication via startAuthFlow(), " + - "followed by token exchange with the Sandbox API." + "This flow requires browser-based authentication via startAuthFlow(), " + + "followed by token exchange with the Sandbox API." ) + + private fun createAccountLabel(idToken: String): String = if (idToken.isNotBlank()) { + try { + val jwt = JWTParser.parse(idToken) as SignedJWT + val claims = jwt.jwtClaimsSet + + claims.getStringClaim("preferred_username") + ?: claims.getStringClaim("email") + ?: "unknown-user" + } catch (e: Exception) { + "unknown-user" + } + } else { + "unknown-user" + } + + private fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" + } + } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index 08add759..93074af3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -80,8 +80,7 @@ class RedHatAuthSessionManager : AbstractAuthSessionManager() { authFlow = RedHatAuthCodeFlow( clientId = authConfig.clientId, redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), - providerMetadata = providerMetadata, - sslContext = sslContext + providerMetadata = providerMetadata ) val request = authFlow.startAuthFlow() From 616522d552a4507867bf7630ae67d8276013631b Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 15:30:37 +0200 Subject: [PATCH 19/32] fix: make Red Hat SSO work with KUBECONFIG env var Signed-off-by: Andre Dietisheim --- .../sandbox/SandboxClusterAuthProvider.kt | 14 +-- .../openshift/OpenShiftClientFactory.kt | 89 ++++++++++++++----- 2 files changed, 75 insertions(+), 28 deletions(-) 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 ba283bd1..b1b3e9b8 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,13 +14,13 @@ 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 io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.models.V1ObjectMeta import io.kubernetes.client.openapi.models.V1Secret import io.kubernetes.client.openapi.models.V1ServiceAccount -import io.kubernetes.client.util.ClientBuilder -import io.kubernetes.client.util.credentials.AccessTokenAuthentication import kotlinx.coroutines.* import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds @@ -29,7 +29,8 @@ 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) @@ -40,11 +41,10 @@ class SandboxClusterAuthProvider( val username = signup.compliantUsername ?: signup.username val namespace = "$username-dev" - val client: ApiClient = ClientBuilder.standard() - .setBasePath(signup.proxyUrl!!) - .setAuthentication(AccessTokenAuthentication(ssoToken.idToken)) + val client = clientFactory + .builder(signup.proxyUrl!!, ssoToken.idToken) + .readTimeout(30, TimeUnit.SECONDS) .build() - .also { it.httpClient = it.httpClient.newBuilder().readTimeout(30, TimeUnit.SECONDS).build() } val coreV1Api = CoreV1Api(client) val pipelineSA = ensurePipelineServiceAccount(coreV1Api, namespace) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt index 28f07b71..da026825 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -19,14 +19,16 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.Config import io.kubernetes.client.util.KubeConfig +import io.kubernetes.client.util.SSLUtils.keyManagers 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 java.io.ByteArrayInputStream import java.security.cert.CertificateFactory import java.util.Base64 +import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -37,6 +39,33 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { 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()) { @@ -63,6 +92,12 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { } } + 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, certificateAuthorityData: CharArray? = null, @@ -72,10 +107,9 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { tlsContext: TlsContext ): ApiClient { - val usingToken = token != null && token.isNotEmpty() - val usingClientCert = clientCertData != null && clientCertData.isNotEmpty() - && clientKeyData != null && clientKeyData.isNotEmpty() - val usingCertificateAuthorityData = certificateAuthorityData != null && certificateAuthorityData.isNotEmpty() + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCertData?.isNotEmpty() == true + && clientKeyData?.isNotEmpty() == true require(usingToken.xor(usingClientCert)) { "Provide either token OR clientCertData + clientKeyData." @@ -86,26 +120,15 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { val client = Config.fromConfig(kubeConfig) + val usingCertificateAuthorityData = certificateAuthorityData?.isNotEmpty() == true val trustManager: X509TrustManager = if (usingCertificateAuthorityData) { - buildTrustManagerFromCaData(certificateAuthorityData) + createTrustManager(certificateAuthorityData) } else { tlsContext.trustManager } - val keyManagers: Array? = - if (usingClientCert) { - buildKeyManagers(clientCertData, clientKeyData) - } else { - null - } - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init( - keyManagers, - arrayOf(trustManager), - SecureRandom() - ) + val sslContext = createSSLContext(trustManager, usingClientCert, clientCertData, clientKeyData) client.httpClient = client.httpClient.newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) @@ -114,7 +137,31 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { return client } - private fun buildTrustManagerFromCaData( + private fun createSSLContext( + trustManager: X509TrustManager, + usingClientCert: Boolean, + clientCertData: CharArray?, + clientKeyData: CharArray? + ): SSLContext { + val keyManagers: Array? = + if (usingClientCert + && clientCertData?.isNotEmpty() == true + && clientKeyData?.isNotEmpty() == true) { + createKeyManagers(clientCertData, clientKeyData) + } else { + null + } + + return SSLContext.getInstance("TLS").apply { + init( + keyManagers, + arrayOf(trustManager), + SecureRandom() + ) + } + } + + private fun createTrustManager( caData: CharArray ): X509TrustManager { @@ -139,7 +186,7 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { .first() } - private fun buildKeyManagers( + private fun createKeyManagers( certData: CharArray, keyData: CharArray ): Array { From c456608e50a1a26751445fa8ee7bbf565601bef4 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Tue, 28 Apr 2026 17:14:28 +0200 Subject: [PATCH 20/32] Fix the Pipeline Token Secret creation (RH SSO) Signed-off-by: Victor Rubezhny --- .../sandbox/SandboxClusterAuthProvider.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 b1b3e9b8..012e9af6 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 @@ -78,8 +78,12 @@ class SandboxClusterAuthProvider( val secretList = api.listNamespacedSecret(namespace).execute() ?: error("Failed to list Secrets") - secretList.items.firstOrNull { it.metadata?.name == secretName && it.data?.containsKey("token") == true } - ?.let { return@withContext it } + val existing = secretList.items.firstOrNull { it.metadata?.name == secretName } + if (existing != null) { + val populated = requestSecret(secretName, namespace, api) + ?: error("Pipeline token secret not populated") + return@withContext populated + } val secret = V1Secret().metadata( V1ObjectMeta() @@ -90,26 +94,27 @@ class SandboxClusterAuthProvider( api.createNamespacedSecret(namespace, secret).execute() - if (requestSecret(secretName, namespace, api)) { - return@withContext secret - } + val populated = requestSecret(secretName, namespace, api) + ?: error("Pipeline token secret not populated") - error("Pipeline token secret not populated") + return@withContext populated } - private suspend fun requestSecret(secretName: String, namespace: String, api: CoreV1Api): Boolean { + private suspend fun requestSecret(secretName: String, namespace: String, api: CoreV1Api): V1Secret? { repeat(30) { val secret = api.readNamespacedSecret(secretName, namespace).execute() if (secret.data?.containsKey("token") == true) { - return true + return secret } delay(1000.milliseconds) } - return false + return null } private fun extractToken(secret: V1Secret): String { - val tokenBytes = secret.data?.get("token") ?: error("Token missing in secret") + val tokenBytes = secret.data?.get("token") + ?: error("Token missing in secret") + return String(tokenBytes, Charsets.UTF_8) } } From df617a60789688db4f287ef90783c9cb10d7bcb0 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 17:41:48 +0200 Subject: [PATCH 21/32] feature: replaced overly verbose error dialog with human readable messaging Signed-off-by: Andre Dietisheim --- .../gateway/openshift/ApiExceptionUtils.kt | 19 ++++++++++++++ .../devtools/gateway/util/ExceptionUtils.kt | 5 ++-- .../view/steps/DevSpacesServerStepView.kt | 20 +++++++++++++- .../auth/RedHatSSOAuthenticationStrategy.kt | 26 ++++++++++++------- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index ad188413..053fc10b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -19,4 +19,23 @@ fun ApiException.isNotFound(): Boolean { fun ApiException.isUnauthorized(): Boolean { return code == 401 +} + +/** + * Converts HTTP status code to human-readable message. + */ +fun ApiException.toUserFriendlyMessage(): String { + val statusMessage = when (code) { + 400 -> "Bad Request" + 401 -> "Unauthorized" + 403 -> "Forbidden" + 404 -> "Not Found" + 408 -> "Request Timeout" + 500 -> "Internal Server Error" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + 504 -> "Gateway Timeout" + else -> "HTTP Error $code" + } + return statusMessage } \ No newline at end of file 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 5d5e52e6..25f31e1b 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -43,12 +43,11 @@ fun Throwable.message(): String { fun ApiException.message(): String { val response = Gson().fromJson(responseBody, Map::class.java) - val msg = try { - response["message"]?.toString() + return try { + response["message"]?.toString() ?: "Unknown error" } catch (e: Exception) { e.rootMessage() } - return "Reason: $msg" } fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationException || this is TimeoutException ) 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 b52f3c8d..70280382 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 @@ -39,6 +39,8 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.openshift.toUserFriendlyMessage +import io.kubernetes.client.openapi.ApiException import com.redhat.devtools.gateway.settings.DevSpacesSettings import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.view.steps.auth.* @@ -340,6 +342,7 @@ class DevSpacesServerStepView( override fun onNext(): Boolean { val selectedCluster = tfServer.selectedItem as? Cluster ?: return false val server = selectedCluster.url + val serverDisplay = server.removePrefix("https://").removePrefix("http://") val strategy = currentStrategy ?: return false var success = false @@ -368,10 +371,25 @@ class DevSpacesServerStepView( } } success = true + } catch (e: ApiException) { + if (!e.isCancellationException()) { + Dialogs.error( + "Could not connect to cluster $serverDisplay.\n\nReason: ${e.toUserFriendlyMessage()}.", + "Connection Failed" + ) + } + } catch (e: IllegalStateException) { + if (!e.isCancellationException()) { + Dialogs.error( + "Could not connect to cluster $serverDisplay.\n\nReason: ${e.message ?: "Unknown error"}", + "Connection Failed" + ) + } } catch (e: Exception) { if (!e.isCancellationException()) { + val serverDisplay = server.removePrefix("https://").removePrefix("http://") Dialogs.error( - e.message ?: "Unable to connect to the cluster", + "Could not connect to cluster $serverDisplay: ${e.message ?: "Unknown error"}", "Connection Failed" ) } 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 4fe08b7f..6bed1e6f 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 @@ -25,6 +25,8 @@ import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.openshift.toUserFriendlyMessage +import io.kubernetes.client.openapi.ApiException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -90,17 +92,23 @@ class RedHatSSOAuthenticationStrategy( reporter.text("Validating cluster access...") - val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." - ) + try { + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, null, null, tlsContext, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) - // Do not save SSO tokens - if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + } + devSpacesContext.client = client + } catch (e: ApiException) { + throw IllegalStateException("${e.toUserFriendlyMessage()}.\n\nVerify that the cluster has Red Hat SSO enabled.", e) + } catch (e: IllegalArgumentException) { + throw IllegalStateException("${e.message}.\n\nVerify that the cluster has Red Hat SSO enabled.", e) } - devSpacesContext.client = client } override fun isNextEnabled(): Boolean = From 5a079c7636aeb0b7179b83a2b80254c7e9c37dba Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 19:39:00 +0200 Subject: [PATCH 22/32] all auth stragies now throw AuthenticationException Signed-off-by: Andre Dietisheim --- .../gateway/openshift/ApiExceptionUtils.kt | 2 +- .../devtools/gateway/util/ExceptionUtils.kt | 30 ------------------- .../view/steps/DevSpacesServerStepView.kt | 12 +------- .../auth/AbstractAuthenticationStrategy.kt | 30 +++++++++++++------ .../steps/auth/AuthenticationException.kt | 17 +++++++++++ ...ClientCertificateAuthenticationStrategy.kt | 16 +++++----- ...nShiftCredentialsAuthenticationStrategy.kt | 8 +++-- .../OpenShiftOAuthAuthenticationStrategy.kt | 8 +++-- .../auth/RedHatSSOAuthenticationStrategy.kt | 13 ++++---- .../steps/auth/TokenAuthenticationStrategy.kt | 8 +++-- 10 files changed, 71 insertions(+), 73 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index 053fc10b..d6707687 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -24,7 +24,7 @@ fun ApiException.isUnauthorized(): Boolean { /** * Converts HTTP status code to human-readable message. */ -fun ApiException.toUserFriendlyMessage(): String { +fun ApiException.codeToReasonPhrase(): String { val statusMessage = when (code) { 400 -> "Bad Request" 401 -> "Unauthorized" 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 25f31e1b..68095d08 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -11,45 +11,15 @@ */ package com.redhat.devtools.gateway.util -import com.google.gson.Gson -import io.kubernetes.client.openapi.ApiException import kotlinx.coroutines.TimeoutCancellationException import java.util.concurrent.CancellationException import java.util.concurrent.TimeoutException - -fun Throwable.rootMessage(): String { - var cause: Throwable? = this - while (cause?.cause != null) { - cause = cause.cause - } - return cause?.message?.trim() - ?: messageWithoutPrefix() - ?: "Unknown error" -} - fun Throwable.messageWithoutPrefix(): String? { return message?.trim() ?: message?.substringAfter(":")?.trim() } -fun Throwable.message(): String { - return if (this is ApiException) { - message() - } else { - message.orEmpty() - } -} - -fun ApiException.message(): String { - val response = Gson().fromJson(responseBody, Map::class.java) - return try { - response["message"]?.toString() ?: "Unknown error" - } catch (e: Exception) { - e.rootMessage() - } -} - fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationException || this is TimeoutException ) fun Throwable.isCancellationException(): Boolean = (this is CancellationException && !isTimeoutException() ) 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 70280382..13131298 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 @@ -39,8 +39,6 @@ import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.Cluster -import com.redhat.devtools.gateway.openshift.toUserFriendlyMessage -import io.kubernetes.client.openapi.ApiException import com.redhat.devtools.gateway.settings.DevSpacesSettings import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.view.steps.auth.* @@ -371,14 +369,7 @@ class DevSpacesServerStepView( } } success = true - } catch (e: ApiException) { - if (!e.isCancellationException()) { - Dialogs.error( - "Could not connect to cluster $serverDisplay.\n\nReason: ${e.toUserFriendlyMessage()}.", - "Connection Failed" - ) - } - } catch (e: IllegalStateException) { + } catch (e: AuthenticationException) { if (!e.isCancellationException()) { Dialogs.error( "Could not connect to cluster $serverDisplay.\n\nReason: ${e.message ?: "Unknown error"}", @@ -387,7 +378,6 @@ class DevSpacesServerStepView( } } catch (e: Exception) { if (!e.isCancellationException()) { - val serverDisplay = server.removePrefix("https://").removePrefix("http://") Dialogs.error( "Could not connect to cluster $serverDisplay: ${e.message ?: "Unknown error"}", "Connection 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 dfbe7691..c65f7538 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 @@ -17,7 +17,9 @@ 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.Projects +import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.ApiException /** * Abstract base class for authentication strategies. @@ -33,7 +35,7 @@ abstract class AbstractAuthenticationStrategy( /** * Creates a validated API client. */ - @Throws(IllegalArgumentException::class) + @Throws(AuthenticationException::class) protected fun createValidatedApiClient( server: String, certificateAuthorityData: String? = null, @@ -42,12 +44,22 @@ abstract class AbstractAuthenticationStrategy( clientKeyPem: String? = null, tlsContext: TlsContext, errorMessage: String? = null - ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) - .create( - server, certificateAuthorityData?.toCharArray(), token?.toCharArray(), - clientCertPem?.toCharArray(), clientKeyPem?.toCharArray(), tlsContext - ) - .also { client -> - require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } - } + ): ApiClient = try { + OpenShiftClientFactory(KubeConfigUtils) + .create( + server, + certificateAuthorityData?.toCharArray(), + token?.toCharArray(), + clientCertPem?.toCharArray(), + clientKeyPem?.toCharArray(), + tlsContext + ) + .also { client -> + require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + } + } catch (e: ApiException) { + throw AuthenticationException(e.codeToReasonPhrase(), e) + } catch (e: IllegalArgumentException) { + throw AuthenticationException(e.message ?: "Authentication failed", e) + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt new file mode 100644 index 00000000..571940b8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +/** + * Exception thrown when authentication fails. + */ +class AuthenticationException(message: String, cause: Throwable? = null) : Exception(message, cause) 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 dd0126cd..cf3e5758 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 @@ -12,7 +12,6 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.platform.util.progress.RawProgressReporter -import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.panel @@ -22,7 +21,6 @@ import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster -import com.redhat.devtools.gateway.openshift.Projects import javax.swing.JPanel /** @@ -78,14 +76,14 @@ class ClientCertificateAuthenticationStrategy( val clientKeyPem = tfClientKey.text val client = createValidatedApiClient( - server, certAuthorityData, - null, clientCertPem, clientKeyPem, tlsContext, - "Authentication failed: invalid server URL or token." - ) - - require(Projects(client).isAuthenticated()) { + server, + certAuthorityData, + null, + clientCertPem, + clientKeyPem, + tlsContext, "Authentication failed: invalid client certificate or key." - } + ) saveKubeconfigCert(selectedCluster, clientCertPem, clientKeyPem, reporter) devSpacesContext.client = client 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 54d1f05a..bfe4b842 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 @@ -120,8 +120,12 @@ class OpenShiftCredentialsAuthenticationStrategy( reporter.text("Validating cluster access...") val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, + server, + certAuthorityData, + finalToken.accessToken, + null, + null, + tlsContext, "Authentication failed: invalid OpenShift credentials." ) 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 0ceb8e01..36adff70 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 @@ -93,8 +93,12 @@ class OpenShiftOAuthAuthenticationStrategy( reporter.text("Validating cluster access...") val client = createValidatedApiClient( - server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, + server, + certAuthorityData, + finalToken.accessToken, + null, + null, + tlsContext, "Authentication failed: token received from OpenShift Authenticator is invalid or expired." ) 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 6bed1e6f..08b67fcf 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 @@ -25,8 +25,6 @@ import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster -import com.redhat.devtools.gateway.openshift.toUserFriendlyMessage -import io.kubernetes.client.openapi.ApiException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -95,7 +93,10 @@ class RedHatSSOAuthenticationStrategy( try { val client = createValidatedApiClient( server, certAuthorityData, - finalToken.accessToken, null, null, tlsContext, + finalToken.accessToken, + null, + null, + tlsContext, "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." ) @@ -104,10 +105,8 @@ class RedHatSSOAuthenticationStrategy( saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) } devSpacesContext.client = client - } catch (e: ApiException) { - throw IllegalStateException("${e.toUserFriendlyMessage()}.\n\nVerify that the cluster has Red Hat SSO enabled.", e) - } catch (e: IllegalArgumentException) { - throw IllegalStateException("${e.message}.\n\nVerify that the cluster has Red Hat SSO enabled.", e) + } catch (e: AuthenticationException) { + throw AuthenticationException("${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", e) } } 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 d1e0ca60..62370e32 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 @@ -98,8 +98,12 @@ class TokenAuthenticationStrategy( val token = String(tfToken.password) val client = createValidatedApiClient( - server, certAuthorityData, - token, null, null, tlsContext, + server, + certAuthorityData, + token, + null, + null, + tlsContext, "Authentication failed: invalid server URL or token." ) From 84e5d515db79a522719c8f5598e238a0e0a60603 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 21:39:08 +0200 Subject: [PATCH 23/32] nested 'Certificate Authority' txt field in collapsible panel Signed-off-by: Andre Dietisheim --- .../view/steps/DevSpacesServerStepView.kt | 18 ++++-- .../gateway/view/ui/CollapsiblePanel.kt | 57 +++++++++++++++++++ .../messages/DevSpacesBundle.properties | 1 + 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt 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 13131298..4ccefce9 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 @@ -22,6 +22,7 @@ import com.intellij.platform.ide.progress.ModalTaskOwner import com.intellij.platform.ide.progress.runWithModalProgressBlocking import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.platform.util.progress.reportRawProgress +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.components.JBTextField @@ -45,6 +46,7 @@ import com.redhat.devtools.gateway.view.steps.auth.* import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.view.ui.collapsible import com.redhat.devtools.gateway.view.ui.requestInitialFocus import kotlinx.coroutines.* import java.awt.event.* @@ -190,11 +192,19 @@ class DevSpacesServerStepView( row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.server")) { cell(tfServer).align(Align.FILL) } - row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority")) { - cell(tfCertAuthorityData).align(Align.FILL) + collapsible( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.advanced_group") + ) { + indent { + row( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority") + ) { + cell(tfCertAuthorityData) + .align(Align.FILL) + .resizableColumn() + } + } } - val tabInsets = UIManager.getInsets("TabbedPane.tabInsets") ?: JBUI.insets(0) - row { label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) .align(AlignY.TOP) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt new file mode 100644 index 00000000..32333b95 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.ui + +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.Panel +import com.intellij.util.ui.UIUtil +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +/** + * Creates a collapsible section with a clickable label toggle. + * + * @param label The text label for the collapsible section + * @param initiallyExpanded Whether the section starts expanded (default: false) + * @param content Lambda that builds the collapsible content using Panel DSL + */ +fun Panel.collapsible( + label: String, + initiallyExpanded: Boolean = false, + content: Panel.() -> Unit +) { + var expanded = initiallyExpanded + lateinit var toggleLabel: JBLabel + lateinit var contentPanel: Panel + + row { + toggleLabel = JBLabel((if (expanded) "▾ " else "▸ ") + label).apply { + foreground = UIUtil.getLabelForeground() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + expanded = !expanded + text = (if (expanded) "▾" else "▸") + " $label" + contentPanel.visible(expanded) + } + }) + } + + cell(toggleLabel) + } + + contentPanel = panel { + content() + }.visible(initiallyExpanded) +} diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index ab461344..02879254 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -12,6 +12,7 @@ connector.wizard_step.openshift_connection.tab.openshift_oauth=OpenShift OAuth connector.wizard_step.openshift_connection.tab.credentials=Username / Password connector.wizard_step.openshift_connection.tab.redhat_sso=Red Hat SSO connector.wizard_step.openshift_connection.label.token=Token: +connector.wizard_step.openshift_connection.label.advanced_group=Advanced Properties connector.wizard_step.openshift_connection.label.certificate_authority=Certificate Authority (PEM): connector.wizard_step.openshift_connection.label.client_certificate=Client Certificate (PEM): connector.wizard_step.openshift_connection.label.client_key=Client Key (PEM): From b0a1dd0e8e3977938ddae383abbfa8a2bfa50040 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 21:45:19 +0200 Subject: [PATCH 24/32] replaced collapsible panel with default implementatin Signed-off-by: Andre Dietisheim --- .../view/steps/DevSpacesServerStepView.kt | 42 ++++++-------- .../gateway/view/ui/CollapsiblePanel.kt | 57 ------------------- .../messages/DevSpacesBundle.properties | 2 +- 3 files changed, 19 insertions(+), 82 deletions(-) delete mode 100644 src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt 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 4ccefce9..219a7fbc 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 @@ -22,12 +22,10 @@ import com.intellij.platform.ide.progress.ModalTaskOwner import com.intellij.platform.ide.progress.runWithModalProgressBlocking import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.platform.util.progress.reportRawProgress -import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.* -import com.intellij.ui.dsl.gridLayout.UnscaledGaps import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.DevSpacesBundle @@ -46,14 +44,14 @@ import com.redhat.devtools.gateway.view.steps.auth.* import com.redhat.devtools.gateway.view.ui.Dialogs import com.redhat.devtools.gateway.view.ui.FilteringComboBox import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu -import com.redhat.devtools.gateway.view.ui.collapsible import com.redhat.devtools.gateway.view.ui.requestInitialFocus import kotlinx.coroutines.* -import java.awt.event.* +import java.awt.event.ItemEvent +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent import java.nio.file.Paths import javax.swing.JComponent import javax.swing.JTextField -import javax.swing.UIManager import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener @@ -192,29 +190,25 @@ class DevSpacesServerStepView( row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.server")) { cell(tfServer).align(Align.FILL) } - collapsible( + collapsibleGroup( DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.advanced_group") ) { - indent { - row( - DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority") - ) { - cell(tfCertAuthorityData) - .align(Align.FILL) - .resizableColumn() - } + row( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority") + ) { + cell(tfCertAuthorityData) + .align(Align.FILL) + .resizableColumn() + } + } + group( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication") + ) { + row { + cell(authTabs) + .align(Align.FILL) } } - row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) - .align(AlignY.TOP) - // This is the standard "nudge" to align a label with - // the text baseline of a adjacent component. - .customize(UnscaledGaps(top = JBUI.scale(16))) - - cell(authTabs) - .align(AlignX.FILL + AlignY.TOP) - }.layout(RowLayout.LABEL_ALIGNED) row { cell(saveKubeconfigCheckbox) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt deleted file mode 100644 index 32333b95..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/CollapsiblePanel.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.view.ui - -import com.intellij.ui.components.JBLabel -import com.intellij.ui.dsl.builder.Panel -import com.intellij.util.ui.UIUtil -import java.awt.Cursor -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent - -/** - * Creates a collapsible section with a clickable label toggle. - * - * @param label The text label for the collapsible section - * @param initiallyExpanded Whether the section starts expanded (default: false) - * @param content Lambda that builds the collapsible content using Panel DSL - */ -fun Panel.collapsible( - label: String, - initiallyExpanded: Boolean = false, - content: Panel.() -> Unit -) { - var expanded = initiallyExpanded - lateinit var toggleLabel: JBLabel - lateinit var contentPanel: Panel - - row { - toggleLabel = JBLabel((if (expanded) "▾ " else "▸ ") + label).apply { - foreground = UIUtil.getLabelForeground() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - expanded = !expanded - text = (if (expanded) "▾" else "▸") + " $label" - contentPanel.visible(expanded) - } - }) - } - - cell(toggleLabel) - } - - contentPanel = panel { - content() - }.visible(initiallyExpanded) -} diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 02879254..448b5532 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -5,7 +5,7 @@ connector.title=Dev Spaces # Wizard OpenShift connection step connector.wizard_step.openshift_connection.title=Connecting to OpenShift API server connector.wizard_step.openshift_connection.label.server=Server: -connector.wizard_step.openshift_connection.label.authentication=Authentication: +connector.wizard_step.openshift_connection.label.authentication=Authentication connector.wizard_step.openshift_connection.tab.token=Token connector.wizard_step.openshift_connection.tab.client_certificate=Client Certificate connector.wizard_step.openshift_connection.tab.openshift_oauth=OpenShift OAuth From 0cbdee45e7a4ea5588af5e3b3c01de4451d16d70 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 28 Apr 2026 23:09:17 +0200 Subject: [PATCH 25/32] cleanup Signed-off-by: Andre Dietisheim --- .../devtools/gateway/auth/code/RedHatAuthCodeFlow.kt | 12 ++++++++++-- .../auth/session/AbstractAuthSessionManager.kt | 2 +- .../auth/session/OpenShiftAuthSessionManager.kt | 2 +- .../gateway/auth/session/RedHatAuthSessionManager.kt | 2 +- .../gateway/auth/session/SsoLoginException.kt | 2 +- .../devtools/gateway/auth/tls/SslContextFactory.kt | 8 +++++--- .../com/redhat/devtools/gateway/auth/tls/TlsProbe.kt | 4 +++- .../gateway/auth/tls/ui/TLSTrustDecisionHandler.kt | 4 ++-- 8 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt index 6b7babe6..922c3e86 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -103,13 +103,21 @@ class RedHatAuthCodeFlow( .POST(HttpRequest.BodyPublishers.ofString(form)) .build() - val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + val response = httpClient.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ).await() + if (response.statusCode() !in 200..299) { error("Token request failed: ${response.statusCode()}\n${response.body()}") } + return createSSOToken(response.body()) + } + + private fun createSSOToken(response: String): SSOToken { val json = Json { ignoreUnknownKeys = true } - val body = json.parseToJsonElement(response.body()).jsonObject + val body = json.parseToJsonElement(response).jsonObject val accessToken = body["access_token"]?.jsonPrimitive?.content ?: error("Missing access_token in token response") diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt index b79e02f5..1ea0e8c9 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt @@ -128,7 +128,7 @@ abstract class AbstractAuthSessionManager( token } catch (e: TimeoutCancellationException) { thisLogger().warn("Login timed out after ${timeoutMs}ms") - throw SsoLoginException.Timeout + throw SsoLoginException.Timeout() } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt index 984bae3b..d3ca0ebc 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -81,7 +81,7 @@ class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) if (params == null) { thisLogger().warn("OAuth callback timed out or was cancelled") - pendingLogin?.completeExceptionally(SsoLoginException.Timeout) + pendingLogin?.completeExceptionally(SsoLoginException.Timeout()) notifyLoginCancelled() return@launch } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index 93074af3..59afabf2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -93,7 +93,7 @@ class RedHatAuthSessionManager : AbstractAuthSessionManager() { if (params == null) { thisLogger().warn("OAuth callback timed out or was cancelled") pendingLogin?.completeExceptionally( - SsoLoginException.Timeout + SsoLoginException.Timeout() ) notifyLoginCancelled() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt index 1f03d131..a8e5d1aa 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt @@ -14,6 +14,6 @@ package com.redhat.devtools.gateway.auth.session import kotlin.Exception sealed class SsoLoginException : Exception() { - object Timeout : SsoLoginException() + class Timeout : SsoLoginException() data class Failed(val reason: String) : SsoLoginException() } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt index 06953f8b..a72c2c6a 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt @@ -18,6 +18,8 @@ import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +private const val SSL_PROTOCOL = "TLS" + object SslContextFactory { fun empty(): TlsContext = @@ -30,7 +32,7 @@ object SslContextFactory { override fun getAcceptedIssuers(): Array = emptyArray() } - val sslContext = SSLContext.getInstance("TLS").apply { + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { init(null, arrayOf(trustAll), SecureRandom()) } @@ -57,7 +59,7 @@ object SslContextFactory { .filterIsInstance() .first() - val sslContext = SSLContext.getInstance("TLS").apply { + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { init(null, tmf.trustManagers, SecureRandom()) } @@ -67,7 +69,7 @@ object SslContextFactory { fun captureOnly(failIfUntrusted: Boolean = true): TlsContext { val capturingTrustManager = CapturingTrustManager(failIfUntrusted) - val sslContext = SSLContext.getInstance("TLS").apply { + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).apply { init( null, arrayOf(capturingTrustManager), diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt index ee3dcc0c..25515ef8 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt @@ -17,9 +17,11 @@ import javax.net.ssl.SSLSocket object TlsProbe { + private const val DEFAULT_HTTPS_PORT = 443 + fun connect(serverUri: URI, sslContext: SSLContext) { val socketFactory = sslContext.socketFactory - val port = if (serverUri.port != -1) serverUri.port else 443 + val port = if (serverUri.port != -1) serverUri.port else DEFAULT_HTTPS_PORT (socketFactory.createSocket(serverUri.host, port) as SSLSocket).use { socket -> socket.startHandshake() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt index 2a1be178..c59a0e6f 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/ui/TLSTrustDecisionHandler.kt @@ -75,14 +75,14 @@ class TLSTrustDecisionHandler( override fun createActions(): Array { return arrayOf( - object : DialogWrapperAction("Trust permanently") { + object : DialogWrapperAction("Trust Permanently") { override fun doAction(e: ActionEvent) { isTrusted = true rememberDecision = true close(OK_EXIT_CODE) } }, - object : DialogWrapperAction("Trust for this session only") { + object : DialogWrapperAction("Trust for This Session Only") { override fun doAction(e: ActionEvent) { isTrusted = true rememberDecision = false From 2d208bbb4631ee75d1e9071b2bfa99e504dea1e8 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 29 Apr 2026 14:03:25 +0200 Subject: [PATCH 26/32] put authenticating via OpenShift OAuth 2nd Signed-off-by: Andre Dietisheim --- .../gateway/view/steps/DevSpacesServerStepView.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 219a7fbc..f2035191 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 @@ -120,17 +120,17 @@ class DevSpacesServerStepView( listOf( tokenStrategy, - ClientCertificateAuthenticationStrategy( + OpenShiftOAuthAuthenticationStrategy( tfServer, ::saveKubeconfig, ::saveKubeconfig, - ::onFieldChanged + setTokenDisplay ), - OpenShiftOAuthAuthenticationStrategy( + ClientCertificateAuthenticationStrategy( tfServer, ::saveKubeconfig, ::saveKubeconfig, - setTokenDisplay + ::onFieldChanged ), OpenShiftCredentialsAuthenticationStrategy( tfServer, From dbe7b14c280985ea4ebbe57c041c79349a3241ed Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 29 Apr 2026 15:19:40 +0200 Subject: [PATCH 27/32] provide only ClientCertificateAuthenticationStrategy with means to save config with cert Signed-off-by: Andre Dietisheim --- .../gateway/view/steps/DevSpacesServerStepView.kt | 8 ++------ .../view/steps/auth/AbstractAuthenticationStrategy.kt | 3 +-- .../steps/auth/ClientCertificateAuthenticationStrategy.kt | 7 +++---- .../auth/OpenShiftCredentialsAuthenticationStrategy.kt | 4 +--- .../steps/auth/OpenShiftOAuthAuthenticationStrategy.kt | 4 +--- .../view/steps/auth/RedHatSSOAuthenticationStrategy.kt | 4 +--- .../view/steps/auth/TokenAuthenticationStrategy.kt | 4 +--- 7 files changed, 10 insertions(+), 24 deletions(-) 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 f2035191..d81c5794 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 @@ -107,7 +107,6 @@ class DevSpacesServerStepView( val tokenStrategy = TokenAuthenticationStrategy( tfServer, ::saveKubeconfig, - ::saveKubeconfig, ::onFieldChanged, ::createEnterKeyListener ) @@ -123,19 +122,17 @@ class DevSpacesServerStepView( OpenShiftOAuthAuthenticationStrategy( tfServer, ::saveKubeconfig, - ::saveKubeconfig, setTokenDisplay ), ClientCertificateAuthenticationStrategy( tfServer, ::saveKubeconfig, - ::saveKubeconfig, + ::saveKubeconfigWithCert, ::onFieldChanged ), OpenShiftCredentialsAuthenticationStrategy( tfServer, ::saveKubeconfig, - ::saveKubeconfig, ::onFieldChanged, ::createEnterKeyListener, setTokenDisplay @@ -143,7 +140,6 @@ class DevSpacesServerStepView( RedHatSSOAuthenticationStrategy( tfServer, ::saveKubeconfig, - ::saveKubeconfig, sessionManager ) ) @@ -484,7 +480,7 @@ class DevSpacesServerStepView( } } - private fun saveKubeconfig(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, reporter: RawProgressReporter) { + private fun saveKubeconfigWithCert(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, reporter: RawProgressReporter) { if (!saveToKubeconfig || cluster == null || clientCertPem.isNullOrBlank() || clientKeyPem.isNullOrBlank()) return try { 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 c65f7538..24bfe2d0 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 @@ -28,8 +28,7 @@ import io.kubernetes.client.openapi.ApiException @Suppress("UnstableApiUsage") abstract class AbstractAuthenticationStrategy( protected val tfServer: Any, // FilteringComboBox - protected val saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - protected val saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit + protected val saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit ) : AuthenticationStrategy { /** 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 cf3e5758..70b8f873 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 @@ -30,12 +30,11 @@ import javax.swing.JPanel class ClientCertificateAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + private val saveKubeconfigWithCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, private val onFieldChanged: () -> DocumentListener ) : AbstractAuthenticationStrategy( tfServer, - saveKubeconfig, - saveKubeconfigCert + saveKubeconfig ) { val tfClientCert = JBTextField().apply { @@ -85,7 +84,7 @@ class ClientCertificateAuthenticationStrategy( "Authentication failed: invalid client certificate or key." ) - saveKubeconfigCert(selectedCluster, clientCertPem, clientKeyPem, reporter) + saveKubeconfigWithCert(selectedCluster, clientCertPem, clientKeyPem, reporter) devSpacesContext.client = client } 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 bfe4b842..5a17298b 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 @@ -38,14 +38,12 @@ import javax.swing.JPanel class OpenShiftCredentialsAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener, private val setTokenDisplay: suspend (String) -> Unit ) : AbstractAuthenticationStrategy( tfServer, - saveKubeconfig, - saveKubeconfigCert + saveKubeconfig ) { private val tfUsername = JBTextField().apply { 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 36adff70..600db2c5 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 @@ -38,12 +38,10 @@ import javax.swing.JPanel class OpenShiftOAuthAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, private val setTokenDisplay: suspend (String) -> Unit ) : AbstractAuthenticationStrategy( tfServer, - saveKubeconfig, - saveKubeconfigCert + saveKubeconfig ) { override fun getAuthMethod(): AuthMethod = AuthMethod.OPENSHIFT 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 08b67fcf..ad7e7dd5 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 @@ -38,12 +38,10 @@ import javax.swing.JPanel class RedHatSSOAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, private val sessionManager: RedHatAuthSessionManager ) : AbstractAuthenticationStrategy( tfServer, - saveKubeconfig, - saveKubeconfigCert + saveKubeconfig ) { override fun getAuthMethod(): AuthMethod = AuthMethod.REDHAT_SSO 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 62370e32..9d788b6e 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 @@ -41,13 +41,11 @@ import javax.swing.JPanel class TokenAuthenticationStrategy( tfServer: Any, saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - saveKubeconfigCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener ) : AbstractAuthenticationStrategy( tfServer, - saveKubeconfig, - saveKubeconfigCert + saveKubeconfig ) { private val tokenFieldWithToggle = PasswordFieldWithToggle().apply { From ef7fd1586573231555435e6b0266611852f08e36 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Wed, 29 Apr 2026 18:26:16 +0200 Subject: [PATCH 28/32] fixed 'Check Connection' button enablement when switching auth method Signed-off-by: Andre Dietisheim --- .../view/steps/auth/AbstractAuthenticationStrategy.kt | 7 +++++++ .../steps/auth/ClientCertificateAuthenticationStrategy.kt | 4 +++- .../auth/OpenShiftCredentialsAuthenticationStrategy.kt | 4 +++- .../steps/auth/OpenShiftOAuthAuthenticationStrategy.kt | 2 +- .../view/steps/auth/RedHatSSOAuthenticationStrategy.kt | 2 +- .../gateway/view/steps/auth/TokenAuthenticationStrategy.kt | 3 ++- 6 files changed, 17 insertions(+), 5 deletions(-) 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 24bfe2d0..42f59c06 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 @@ -31,6 +31,13 @@ abstract class AbstractAuthenticationStrategy( protected val saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit ) : AuthenticationStrategy { + /** + * Checks if a server/cluster has been selected. + */ + protected fun isServerSelected(): Boolean { + return (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null + } + /** * Creates a validated API client. */ 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 70b8f873..59c9c574 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 @@ -89,5 +89,7 @@ class ClientCertificateAuthenticationStrategy( } override fun isNextEnabled(): Boolean = - tfClientCert.text.isNotBlank() && tfClientKey.text.isNotBlank() + isServerSelected() + && tfClientCert.text.isNotBlank() + && tfClientKey.text.isNotBlank() } 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 5a17298b..0e346feb 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 @@ -133,5 +133,7 @@ class OpenShiftCredentialsAuthenticationStrategy( } override fun isNextEnabled(): Boolean = - tfUsername.text.isNotBlank() && tfPassword.password?.isNotEmpty() == true + isServerSelected() + && tfUsername.text.isNotBlank() + && tfPassword.password?.isNotEmpty() == true } 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 600db2c5..b810d528 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 @@ -106,5 +106,5 @@ class OpenShiftOAuthAuthenticationStrategy( } override fun isNextEnabled(): Boolean = - (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null + isServerSelected() } 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 ad7e7dd5..c1e3a160 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 @@ -109,5 +109,5 @@ class RedHatSSOAuthenticationStrategy( } override fun isNextEnabled(): Boolean = - (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null + isServerSelected() } 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 9d788b6e..0c5d9af0 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 @@ -110,7 +110,8 @@ class TokenAuthenticationStrategy( } override fun isNextEnabled(): Boolean = - tfToken.password?.isNotEmpty() == true + isServerSelected() + && tfToken.password?.isNotEmpty() == true /** * Start monitoring clipboard for tokens. From d0fcc46f5cbb6cedeea004e68479bd6fe74f0683 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 4 May 2026 14:51:00 +0200 Subject: [PATCH 29/32] fix: corrected background color for auth tabs Signed-off-by: Andre Dietisheim --- .../gateway/view/steps/DevSpacesServerStepView.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 d81c5794..6d63876a 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 @@ -158,18 +158,22 @@ class DevSpacesServerStepView( } private fun tabPanel(p: JComponent): JComponent = - p.apply { - isOpaque = false + JBUI.Panels.simplePanel(p).apply { + isOpaque = true background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + border = JBUI.Borders.emptyTop(16) } private val authTabs = JBTabbedPane().apply { - isOpaque = false + isOpaque = true background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() // Add tabs for each strategy authStrategies.forEach { strategy -> - addTab(strategy.getTabTitle(), tabPanel(strategy.createPanel())) + val panel = strategy.createPanel() + // Make inner panel transparent so wrapper background shows through + panel.isOpaque = false + addTab(strategy.getTabTitle(), tabPanel(panel)) } addChangeListener { From fec64610eaacdb0ddebb8ab0623cf8d0506699fd Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Thu, 30 Apr 2026 21:23:26 +0200 Subject: [PATCH 30/32] feat: support file paths for certificates in kubeconfig Add support for both file paths and base64 data in certificate fields. Kubeconfig can use either certificate-authority-data (base64) or certificate-authority (file path). Same for client-certificate and client-key. Changes: Data model: - Add CertificateSource to track value, format (path vs data), and modification state - Auto-detect input type (path vs base64/PEM) in fromUserInput() - Expand ~ to absolute paths when storing Kubeconfig support: - KubeConfigCluster: use CertificateSource for certificateAuthority - KubeConfigUser: use CertificateSource for clientCertificate/clientKey - Read both -data and path fields when loading - Write correct field type when saving (preserve original format) - Cluster data class updated with CertificateSource fields SSL context: - OpenShiftClientFactory: add resolveCertificateSource() to read files - createTrustManager/createKeyManagers accept CertificateSource - Read file content when isFilePath=true before parsing - Deprecated old CharArray-based methods for backward compatibility UI: - Add Browse buttons to Certificate Authority, Client Certificate, Client Key fields - Auto-detect whether user entered path or data - Display certificate values from loaded configs (both formats) - Remove "(PEM)" from field labels (now format-agnostic) This allows connecting to clusters like minikube that use file paths in kubeconfig instead of embedded base64 data. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Andre Dietisheim --- .serena/cache/kotlin/document_symbols.pkl | Bin 0 -> 2120205 bytes .serena/cache/kotlin/raw_document_symbols.pkl | Bin 0 -> 234700 bytes .serena/project.local.yml | 5 + .../auth/tls/CertificateFileChooser.kt | 38 +++ .../gateway/auth/tls/CertificateSource.kt | 138 +++++++++ .../gateway/auth/tls/KubeConfigTlsUtils.kt | 13 +- .../devtools/gateway/auth/tls/PemUtils.kt | 100 ++++++- .../gateway/kubeconfig/KubeConfigEntries.kt | 73 ++++- .../gateway/kubeconfig/KubeConfigUpdate.kt | 5 +- .../gateway/kubeconfig/KubeConfigUtils.kt | 22 +- .../devtools/gateway/openshift/Cluster.kt | 11 +- .../openshift/OpenShiftClientFactory.kt | 122 ++++---- .../devtools/gateway/openshift/Projects.kt | 13 +- .../view/steps/DevSpacesServerStepView.kt | 25 +- .../auth/AbstractAuthenticationStrategy.kt | 15 +- ...ClientCertificateAuthenticationStrategy.kt | 17 +- .../messages/DevSpacesBundle.properties | 6 +- .../gateway/auth/tls/CertificateSourceTest.kt | 282 ++++++++++++++++++ .../devtools/gateway/auth/tls/PemUtilsTest.kt | 154 ++++++++++ .../kubeconfig/KubeConfigClusterTest.kt | 39 ++- .../kubeconfig/KubeConfigMonitorTest.kt | 2 + .../gateway/kubeconfig/KubeConfigUserTest.kt | 60 ++-- .../devtools/gateway/openshift/ClusterTest.kt | 200 +++++-------- 23 files changed, 1083 insertions(+), 257 deletions(-) create mode 100644 .serena/cache/kotlin/document_symbols.pkl create mode 100644 .serena/cache/kotlin/raw_document_symbols.pkl create mode 100644 .serena/project.local.yml create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt diff --git a/.serena/cache/kotlin/document_symbols.pkl b/.serena/cache/kotlin/document_symbols.pkl new file mode 100644 index 0000000000000000000000000000000000000000..795bc531f3a5d3a4e6a593eb4e6856a6dc66759a GIT binary patch literal 2120205 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

wXsNebort(Y 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 *&6u+!__ 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ƬyLDi@S6^4$lF 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

Bw05j?L%-NX~{4p7bXL+TiL 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 z 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 literal 0 HcmV?d00001 diff --git a/.serena/cache/kotlin/raw_document_symbols.pkl b/.serena/cache/kotlin/raw_document_symbols.pkl new file mode 100644 index 0000000000000000000000000000000000000000..2eeb1438320bce224ca0807fe06aa38bf9927c9a GIT binary patch literal 234700 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

p3q5>rNYALOGx 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 DWl8~X literal 0 HcmV?d00001 diff --git a/.serena/project.local.yml b/.serena/project.local.yml new file mode 100644 index 00000000..36cd3def --- /dev/null +++ b/.serena/project.local.yml @@ -0,0 +1,5 @@ +# This file allows you to locally override settings in project.yml for development purposes. +# +# Use the same keys as in project.yml here. Any setting you specify will override the corresponding +# setting in project.yml, allowing you to customise the configuration for your local development environment +# without affecting the project configuration in project.yml (which is intended to be versioned). diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt new file mode 100644 index 00000000..8dd53e74 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt @@ -0,0 +1,38 @@ +/* + * 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.auth.tls + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.ui.components.JBTextField +import java.awt.KeyboardFocusManager +import javax.swing.SwingUtilities + +fun browseCertificate(textField: JBTextField, title: String) { + val descriptor = FileChooserDescriptorFactory.singleFile() + .withTitle(title) + .withDescription("Select a certificate or key file") + + val parent = SwingUtilities.getWindowAncestor(textField) + ?: KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + + val chooser = FileChooser.chooseFile( + descriptor, + parent, + null, + null + ) + + chooser?.let { virtualFile -> + textField.text = virtualFile.path + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt new file mode 100644 index 00000000..59c8783a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt @@ -0,0 +1,138 @@ +/* + * 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.auth.tls + +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.exists +import kotlin.io.path.isReadable + +/** + * Represents a certificate or key source, tracking both the value and its format. + * + * @property value The certificate/key data - either a file path or base64-encoded content + * @property isFilePath True if value is a file path, false if it's base64 data + * @property isModified True if the user has changed this value from what was loaded + */ +data class CertificateSource( + val value: String, + val isFilePath: Boolean, + val isModified: Boolean = false +) { + companion object { + /** + * Creates a CertificateSource from kubeconfig data field (base64). + */ + fun fromData(data: String): CertificateSource { + return CertificateSource( + value = data, + isFilePath = false, + isModified = false + ) + } + + /** + * Creates a CertificateSource from kubeconfig path field. + */ + fun fromPath(path: String): CertificateSource { + return CertificateSource( + value = path, + isFilePath = true, + isModified = false + ) + } + + /** + * Detects input type and creates appropriate CertificateSource. + * Marks as modified since this is user input. + * + * If input is PEM content (not a file path), it will be normalized + * (newlines fixed if needed) and base64-encoded for storage in kubeconfig. + */ + fun fromPathOrPem(input: String): CertificateSource { + val trimmed = input.trim() + val isPath = detectIsPath(trimmed) + + val value = if (isPath) { + expandPath(trimmed) + } else { + // It's PEM or base64 content - normalize and ensure base64-encoded + val normalizedPem = if (PemUtils.isPem(trimmed)) { + PemUtils.ensureProperFormat(trimmed) + } else { + trimmed // Already base64 + } + PemUtils.toBase64(normalizedPem) + } + + return CertificateSource( + value = value, + isFilePath = isPath, + isModified = true + ) + } + + private fun detectIsPath(input: String): Boolean { + // Check for path-like patterns + return when { + input.startsWith("/") -> true + input.startsWith("~/") -> true + input.startsWith("./") -> true + input.startsWith("../") -> true + input.matches(Regex("^[A-Z]:\\\\.*")) -> true // Windows path + PemUtils.isPem(input) -> false + input.length < 50 && input.matches(Regex("^[a-zA-Z0-9._-]+$")) -> true + input.length > 50 && input.matches(Regex("^[A-Za-z0-9+/=\\s]+$")) -> false // Base64 (min length) + else -> true // Default to path for ambiguous cases + } + } + + private fun expandPath(path: String): String { + return when { + path.startsWith("~/") -> { + val home = System.getProperty("user.home") + home + path.substring(1) + } + else -> path + } + } + } + + /** + * Returns the value as a Path if it represents a file path. + */ + fun toPath(): Path = Paths.get(value) + + /** + * Validates the certificate source. + * For file paths, checks existence and readability. + * For base64 data, ensures it's not empty. + */ + fun validate() { + if (isFilePath) { + val path = toPath() + if (!path.exists()) { + throw java.io.FileNotFoundException("Certificate file not found: $value") + } + if (!path.isReadable()) { + throw java.io.IOException("Cannot read certificate file: $value") + } + } + } + + /** + * Creates a copy marked as modified. + */ + fun asModified(): CertificateSource { + return copy(isModified = true) + } +} 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 3d002dfb..8d31a407 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 @@ -17,6 +17,8 @@ import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 +import kotlin.io.path.readText + object KubeConfigTlsUtils { fun findClusterByServer( @@ -31,12 +33,17 @@ object KubeConfigTlsUtils { fun extractCaCertificates( namedCluster: KubeConfigNamedCluster ): List { - val caData = namedCluster.cluster.certificateAuthorityData ?: return emptyList() - val decoded = Base64.getDecoder().decode(caData) + val caSource = namedCluster.cluster.certificateAuthority ?: return emptyList() + val caContent = if (caSource.isFilePath) { + caSource.toPath().readText() + } else { + Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + } + val factory = CertificateFactory.getInstance("X.509") return factory - .generateCertificates(decoded.inputStream()) + .generateCertificates(caContent.byteInputStream()) .filterIsInstance() } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt index 2ef1aacb..bf203e19 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt @@ -20,6 +20,27 @@ import java.util.* object PemUtils { + /** + * Detects whether content is PEM-encoded (as opposed to base64). + * + * @param content The content to check whether it's PEM-encoded. + * @returns `true` if the given string is PEM-encoded. Returns `false` otherwise. + */ + fun isPem(content: String): Boolean = content.contains("-----BEGIN") + + /** + * Ensures PEM has proper formatting with newlines. + * Reformats single-line or malformed PEM (e.g., from JBTextField paste). + */ + fun ensureProperFormat(pem: String): String { + val newlineCount = pem.count { it == '\n' } + return if (newlineCount < 2) { + reformatSingleLinePem(pem) + } else { + pem + } + } + fun toPem(certificate: X509Certificate): String { val base64 = Base64.getMimeEncoder(64, "\n".toByteArray()) .encodeToString(certificate.encoded) @@ -31,14 +52,41 @@ object PemUtils { } } + /** + * Base64-encodes PEM content; passes through already-base64 content unchanged. + */ + fun toBase64(value: String): String { + return if (isPem(value)) { + Base64.getEncoder().encodeToString(value.toByteArray()) + } else { + value + } + } + fun parsePrivateKey(pemOrBase64: String): PrivateKey { val normalized = normalizePem(pemOrBase64) - val cleaned = normalized + // JBTextField can strip or mangle newlines from pasted PEM → reformat if needed + val reformatted = if (isPem(normalized)) { + // Count newlines - properly formatted PEM should have multiple lines + val newlineCount = normalized.count { it == '\n' } + // Reformat if single-line or has very few newlines (malformed) + if (newlineCount < 2) { + reformatSingleLinePem(normalized) + } else { + normalized + } + } else { + normalized + } + + val cleaned = reformatted .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("-----BEGIN RSA PRIVATE KEY-----", "") .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", "") .replace("\\s".toRegex(), "") val decoded = Base64.getDecoder().decode(cleaned) @@ -61,7 +109,7 @@ object PemUtils { private fun normalizePem(input: String): String { val trimmed = input.trim() - return if (!trimmed.contains("BEGIN")) { + return if (!isPem(trimmed)) { // It's base64 from kubeconfig → decode to PEM String(Base64.getDecoder().decode(trimmed)) } else { @@ -72,9 +120,51 @@ object PemUtils { fun parseCertificate(pemOrBase64: String): X509Certificate { val normalized = normalizePem(pemOrBase64) + // JBTextField can strip or mangle newlines from pasted PEM → reformat if needed + val cleaned = if (isPem(normalized)) { + // Count newlines - properly formatted PEM should have multiple lines + val newlineCount = normalized.count { it == '\n' } + // Reformat if single-line or has very few newlines (malformed) + if (newlineCount < 2) { + reformatSingleLinePem(normalized) + } else { + normalized + } + } else { + normalized + } + val factory = CertificateFactory.getInstance("X.509") - return factory.generateCertificate( - normalized.byteInputStream() - ) as X509Certificate + return factory.generateCertificate(cleaned.byteInputStream()) as X509Certificate + } + + /** + * Reformats PEM that has newlines stripped or malformed (from text field paste). + * Extracts base64 content and rebuilds proper PEM format with line breaks. + * Handles all PEM types (CERTIFICATE, PRIVATE KEY, RSA PRIVATE KEY, EC PRIVATE KEY, etc.). + */ + private fun reformatSingleLinePem(singleLinePem: String): String { + // Match any PEM header/footer (CERTIFICATE, PRIVATE KEY, PUBLIC KEY, etc.) + val beginMarker = Regex("-----BEGIN [A-Z ]+-----") + val endMarker = Regex("-----END [A-Z ]+-----") + + val beginMatch = beginMarker.find(singleLinePem) ?: return singleLinePem + val endMatch = endMarker.find(singleLinePem) ?: return singleLinePem + + val header = beginMatch.value + val footer = endMatch.value + val base64Content = singleLinePem.substring( + beginMatch.range.last + 1, + endMatch.range.first + ).replace("\\s+".toRegex(), "") // Remove all whitespace (spaces, newlines, tabs) + + if (base64Content.isEmpty()) { + throw IllegalArgumentException("No certificate data found between PEM markers") + } + + // Rebuild with proper line breaks (64 chars per line, standard PEM format) + val formattedBase64 = base64Content.chunked(64).joinToString("\n") + + return "$header\n$formattedBase64\n$footer\n" } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt index 39434c77..641c3fe8 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -11,6 +11,8 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.sanitizeName import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.urlToName import io.kubernetes.client.util.KubeConfig @@ -52,15 +54,24 @@ data class KubeConfigNamedCluster( data class KubeConfigCluster( val server: String, - val certificateAuthorityData: String? = null, + val certificateAuthority: CertificateSource? = null, val insecureSkipTlsVerify: Boolean? = null ) { companion object { fun fromMap(map: Map<*, *>): KubeConfigCluster? { val server = map["server"] as? String ?: return null + + val caSource = when { + map["certificate-authority-data"] is String -> + CertificateSource.fromData(map["certificate-authority-data"] as String) + map["certificate-authority"] is String -> + CertificateSource.fromPath(map["certificate-authority"] as String) + else -> null + } + return KubeConfigCluster( server = server, - certificateAuthorityData = map["certificate-authority-data"] as? String, + certificateAuthority = caSource, insecureSkipTlsVerify = map["insecure-skip-tls-verify"] as? Boolean ) } @@ -69,7 +80,15 @@ data class KubeConfigCluster( fun toMap(): MutableMap { val map = mutableMapOf() map["server"] = server - certificateAuthorityData?.let { map["certificate-authority-data"] = it } + + certificateAuthority?.let { ca -> + if (ca.isFilePath) { + map["certificate-authority"] = ca.value + } else { + map["certificate-authority-data"] = PemUtils.toBase64(ca.value) + } + } + insecureSkipTlsVerify?.let { map["insecure-skip-tls-verify"] = it } return map } @@ -201,7 +220,7 @@ data class KubeConfigNamedUser( return fromMap(userObject)?.user?.token } - fun getUserClientCertForCluster(clusterName: String, kubeConfig: KubeConfig): Pair? { + fun getUserClientCertForCluster(clusterName: String, kubeConfig: KubeConfig): Pair? { val contextEntry = KubeConfigNamedContext.getByName(clusterName, kubeConfig) ?: return null val userObject = (kubeConfig.users as? List<*>)?.firstOrNull { userObject -> val userMap = userObject as? Map<*, *> ?: return@firstOrNull false @@ -209,7 +228,7 @@ data class KubeConfigNamedUser( userName == contextEntry.context.user } as? Map<*,*> ?: return null val user = fromMap(userObject)?.user - return Pair(user?.clientCertificateData, user?.clientKeyData) + return Pair(user?.clientCertificate, user?.clientKey) } fun isTokenAuth(kubeConfig: KubeConfig): Boolean { @@ -227,17 +246,33 @@ data class KubeConfigNamedUser( data class KubeConfigUser( var token: String? = null, - val clientCertificateData: String? = null, - val clientKeyData: String? = null, + val clientCertificate: CertificateSource? = null, + val clientKey: CertificateSource? = null, val username: String? = null, val password: String? = null ) { companion object { fun fromMap(map: Map<*, *>): KubeConfigUser { + val certSource = when { + map["client-certificate-data"] is String -> + CertificateSource.fromData(map["client-certificate-data"] as String) + map["client-certificate"] is String -> + CertificateSource.fromPath(map["client-certificate"] as String) + else -> null + } + + val keySource = when { + map["client-key-data"] is String -> + CertificateSource.fromData(map["client-key-data"] as String) + map["client-key"] is String -> + CertificateSource.fromPath(map["client-key"] as String) + else -> null + } + return KubeConfigUser( token = map["token"] as? String, - clientCertificateData = map["client-certificate-data"] as? String, - clientKeyData = map["client-key-data"] as? String, + clientCertificate = certSource, + clientKey = keySource, username = map["username"] as? String, password = map["password"] as? String ) @@ -247,8 +282,23 @@ data class KubeConfigUser( fun toMap(): MutableMap { val map = mutableMapOf() token?.let { map["token"] = it } - clientCertificateData?.let { map["client-certificate-data"] = it } - clientKeyData?.let { map["client-key-data"] = it } + + clientCertificate?.let { cert -> + if (cert.isFilePath) { + map["client-certificate"] = cert.value + } else { + map["client-certificate-data"] = PemUtils.toBase64(cert.value) + } + } + + clientKey?.let { key -> + if (key.isFilePath) { + map["client-key"] = key.value + } else { + map["client-key-data"] = PemUtils.toBase64(key.value) + } + } + username?.let { map["username"] = it } password?.let { map["password"] = it } return map @@ -256,4 +306,3 @@ data class KubeConfigUser( } - diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt index da681b71..b492f5d7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.text.UniqueNameGenerator +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import com.redhat.devtools.gateway.openshift.Utils import io.kubernetes.client.util.KubeConfig @@ -299,8 +300,8 @@ abstract class KubeConfigUpdate private constructor( return KubeConfigNamedUser( KubeConfigUser( token = null, - clientCertificateData = clientCertPem, - clientKeyData = clientKeyPem + clientCertificate = CertificateSource.fromData(clientCertPem), + clientKey = CertificateSource.fromData(clientKeyPem) ), uniqueUserName ) 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 e8e0db40..36de5cdf 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.EnvironmentUtil +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.openshift.Cluster import io.kubernetes.client.util.KubeConfig import java.io.File @@ -44,10 +45,10 @@ object KubeConfigUtils { kubeConfig.clusters?.mapNotNull { cluster -> val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null val token = KubeConfigNamedUser.getUserTokenForCluster(namedCluster.name, kubeConfig) - val clientCert:Pair? = KubeConfigNamedUser.getUserClientCertForCluster(namedCluster.name, kubeConfig) - val clientCertData = clientCert?.first - val clientKeyData = clientCert?.second - val cluster = toCluster(namedCluster, token, clientCertData, clientKeyData) + val clientCert = KubeConfigNamedUser.getUserClientCertForCluster(namedCluster.name, kubeConfig) + val clientCertSource = clientCert?.first + val clientKeySource = clientCert?.second + val cluster = toCluster(namedCluster, token, clientCertSource, clientKeySource) logger.debug("Parsed cluster: ${cluster.name} at ${cluster.url}") cluster } ?: emptyList() @@ -84,14 +85,19 @@ object KubeConfigUtils { } } - private fun toCluster(clusterEntry: KubeConfigNamedCluster, userToken: String?, - clientCertData: String?, clientKeyData: String? ): Cluster { + private fun toCluster( + clusterEntry: KubeConfigNamedCluster, + userToken: String?, + clientCertSource: CertificateSource?, + clientKeySource: CertificateSource? + ): Cluster { return Cluster( url = clusterEntry.cluster.server, name = clusterEntry.name, + certificateAuthority = clusterEntry.cluster.certificateAuthority, token = userToken, - clientCertData = clientCertData, - clientKeyData = clientKeyData + clientCert = clientCertSource, + clientKey = clientKeySource ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt index e6cf7205..1ee05eec 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -11,23 +11,24 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toName import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toUriWithHost data class Cluster( val name: String, val url: String, - val certificateAuthorityData: String? = null, + val certificateAuthority: CertificateSource? = null, val token: String? = null, - val clientCertData: String? = null, - val clientKeyData: String? = null + val clientCert: CertificateSource? = null, + val clientKey: CertificateSource? = null ) { init { - require(!(token != null && clientCertData != null)) { + require(!(token != null && clientCert != null)) { "Cluster cannot have both token and client certificate authentication" } - require((clientCertData == null) == (clientKeyData == null)) { + require((clientCert == null) == (clientKey == null)) { "Client certificate and key must both be provided or both be null" } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt index da026825..8561e361 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/OpenShiftClientFactory.kt @@ -12,6 +12,7 @@ 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 @@ -19,15 +20,12 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.Config import io.kubernetes.client.util.KubeConfig -import io.kubernetes.client.util.SSLUtils.keyManagers +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 java.io.ByteArrayInputStream -import java.security.cert.CertificateFactory -import java.util.Base64 import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager @@ -100,35 +98,33 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { fun create( server: String, - certificateAuthorityData: CharArray? = null, + certificateAuthority: CertificateSource? = null, token: CharArray? = null, - clientCertData: CharArray? = null, - clientKeyData: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null, tlsContext: TlsContext ): ApiClient { val usingToken = token?.isNotEmpty() == true - val usingClientCert = clientCertData?.isNotEmpty() == true - && clientKeyData?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null require(usingToken.xor(usingClientCert)) { - "Provide either token OR clientCertData + clientKeyData." + "Provide either token OR clientCert + clientKey." } - val kubeConfig = createKubeConfig(server, certificateAuthorityData, token, clientCertData, clientKeyData) + val kubeConfig = createKubeConfig(server, null, token, clientCert, clientKey) lastUsedKubeConfig = kubeConfig val client = Config.fromConfig(kubeConfig) - val usingCertificateAuthorityData = certificateAuthorityData?.isNotEmpty() == true val trustManager: X509TrustManager = - if (usingCertificateAuthorityData) { - createTrustManager(certificateAuthorityData) + if (certificateAuthority != null) { + createTrustManager(certificateAuthority) } else { tlsContext.trustManager } - val sslContext = createSSLContext(trustManager, usingClientCert, clientCertData, clientKeyData) + val sslContext = createSSLContext(trustManager, usingClientCert, clientCert, clientKey) client.httpClient = client.httpClient.newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) @@ -137,17 +133,31 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { return client } + /** + * Resolves CertificateSource to actual content. + * If it's a file path, reads the file. Otherwise returns the value. + */ + private fun resolveCertificateSource(source: CertificateSource): String { + return if (source.isFilePath) { + try { + source.toPath().readText() + } catch (e: Exception) { + throw java.io.IOException("Failed to read certificate file: ${source.value}", e) + } + } else { + source.value + } + } + private fun createSSLContext( trustManager: X509TrustManager, usingClientCert: Boolean, - clientCertData: CharArray?, - clientKeyData: CharArray? + clientCert: CertificateSource?, + clientKey: CertificateSource? ): SSLContext { val keyManagers: Array? = - if (usingClientCert - && clientCertData?.isNotEmpty() == true - && clientKeyData?.isNotEmpty() == true) { - createKeyManagers(clientCertData, clientKeyData) + if (usingClientCert && clientCert != null && clientKey != null) { + createKeyManagers(clientCert, clientKey) } else { null } @@ -162,15 +172,11 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { } private fun createTrustManager( - caData: CharArray + caSource: CertificateSource ): X509TrustManager { - val decoded = Base64.getDecoder().decode(String(caData)) - - val certificateFactory = CertificateFactory.getInstance("X.509") - val caCert = certificateFactory.generateCertificate( - ByteArrayInputStream(decoded) - ) + val caContent = resolveCertificateSource(caSource) + val caCert = PemUtils.parseCertificate(caContent) val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) keyStore.load(null, null) @@ -187,19 +193,15 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { } private fun createKeyManagers( - certData: CharArray, - keyData: CharArray + certSource: CertificateSource, + keySource: CertificateSource ): Array { - val certBytes = Base64.getDecoder().decode(String(certData)) - val keyBytes = Base64.getDecoder().decode(String(keyData)) - - val certificateFactory = CertificateFactory.getInstance("X.509") - val certificate = certificateFactory.generateCertificate( - ByteArrayInputStream(certBytes) - ) + val certContent = resolveCertificateSource(certSource) + val keyContent = resolveCertificateSource(keySource) - val privateKey = PemUtils.parsePrivateKey(String(keyBytes)) + val certificate = PemUtils.parseCertificate(certContent) + val privateKey = PemUtils.parsePrivateKey(keyContent) val keyStore = KeyStore.getInstance("PKCS12") keyStore.load(null) @@ -219,27 +221,31 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { return kmf.keyManagers } - private fun createKubeConfig(server: String, certificateAuthorityData: CharArray? = null, token: CharArray? = null, - clientCertData: CharArray? = null, clientKeyData: CharArray? = null + private fun createKubeConfig( + server: String, + certificateAuthority: CertificateSource? = null, + token: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null ): KubeConfig { val usingToken = token != null - val usingClientCert = clientCertData != null && clientKeyData != null + val usingClientCert = clientCert != null && clientKey != null require(usingToken.xor(usingClientCert)) { - "Provide either token OR clientCertData + clientKeyData." + "Provide either token OR clientCert + clientKey." } val cluster = mutableMapOf( "server" to server.trim() ) - val caData = certificateAuthorityData - ?.let { String(it).trim() } - ?.takeIf { it.isNotEmpty() } - - if (caData != null) { - cluster["certificate-authority-data"] = caData + 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( @@ -252,10 +258,20 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { if (usingToken) { userAuth["token"] = String(token).trim() } else { - userAuth["client-certificate-data"] = - String(clientCertData!!).trim() - userAuth["client-key-data"] = - String(clientKeyData!!).trim() + clientCert?.let { cert -> + if (cert.isFilePath) { + userAuth["client-certificate"] = cert.value.trim() + } else { + userAuth["client-certificate-data"] = PemUtils.toBase64(cert.value.trim()) + } + } + clientKey?.let { key -> + if (key.isFilePath) { + userAuth["client-key"] = key.value.trim() + } else { + userAuth["client-key-data"] = PemUtils.toBase64(key.value.trim()) + } + } } val userEntry = mapOf( @@ -276,4 +292,4 @@ class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { return kubeConfig } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt index 14856a0d..acbd4d58 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt @@ -34,19 +34,12 @@ class Projects(private val client: ApiClient) { return response["items"] as List<*> } - @Throws(ApiException::class) - fun isAuthenticated(): Boolean { - list() - // throws if not authenticated - return true - } - /** - * Check if the token is valid and usable for the namespace. - * Works for user OAuth tokens and pipeline SA tokens. + * Checks if the current authenticated identity can access namespaces (works for Kubernetes and OpenShift). + * Returns `true` if the client is authorized. Returns `false` otherwise. */ @Throws(ApiException::class) - fun isAuthenticatedAlternative(): Boolean { + fun isAuthenticated(): Boolean { val api = AuthorizationV1Api(client) val review = V1SelfSubjectAccessReview().apply { 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 6d63876a..08626a43 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 @@ -22,6 +22,7 @@ import com.intellij.platform.ide.progress.ModalTaskOwner import com.intellij.platform.ide.progress.runWithModalProgressBlocking import com.intellij.platform.util.progress.RawProgressReporter import com.intellij.platform.util.progress.reportRawProgress +import com.redhat.devtools.gateway.auth.tls.browseCertificate import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.components.JBTextField @@ -85,7 +86,7 @@ class DevSpacesServerStepView( ApplicationManager.getApplication() .getService(RedHatAuthSessionManager::class.java) - private var tfCertAuthorityData = JBTextField() + private var tfCertAuthority = JBTextField() .apply { document.addDocumentListener(onFieldChanged()) PasteClipboardMenu.addTo(this) @@ -196,9 +197,13 @@ class DevSpacesServerStepView( row( DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority") ) { - cell(tfCertAuthorityData) + cell(tfCertAuthority) .align(Align.FILL) .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfCertAuthority, "Select Certificate Authority File") + } } } group( @@ -254,13 +259,13 @@ class DevSpacesServerStepView( if (event.stateChange == ItemEvent.SELECTED) { (event.item as? Cluster)?.let { selectedCluster -> if (allClusters.contains(selectedCluster)) { - tfCertAuthorityData.text = selectedCluster.certificateAuthorityData + tfCertAuthority.text = selectedCluster.certificateAuthority?.value ?: "" findStrategy()?.tfToken?.apply { text = selectedCluster.token } findStrategy()?.apply { - tfClientCert.text = selectedCluster.clientCertData - tfClientKey.text = selectedCluster.clientKeyData + tfClientCert.text = selectedCluster.clientCert?.value ?: "" + tfClientKey.text = selectedCluster.clientKey?.value ?: "" } saveKubeconfigCheckbox.isSelected = false } @@ -359,7 +364,7 @@ class DevSpacesServerStepView( reporter.text("Connecting to cluster...") val tlsContext = resolveSslContext(server) - val certAuthorityData = tfCertAuthorityData.text + val certAuthorityData = tfCertAuthority.text strategy.authenticate( selectedCluster, @@ -375,6 +380,7 @@ class DevSpacesServerStepView( success = true } catch (e: AuthenticationException) { if (!e.isCancellationException()) { + thisLogger().warn(e) Dialogs.error( "Could not connect to cluster $serverDisplay.\n\nReason: ${e.message ?: "Unknown error"}", "Connection Failed" @@ -382,6 +388,7 @@ class DevSpacesServerStepView( } } catch (e: Exception) { if (!e.isCancellationException()) { + thisLogger().warn(e) Dialogs.error( "Could not connect to cluster $serverDisplay: ${e.message ?: "Unknown error"}", "Connection Failed" @@ -516,13 +523,13 @@ class DevSpacesServerStepView( ?: clusters.firstOrNull { it.id == saved?.id } ?: clusters.firstOrNull() tfServer.selectedItem = toSelect - tfCertAuthorityData.text = toSelect?.certificateAuthorityData ?: "" + tfCertAuthority.text = toSelect?.certificateAuthority?.value ?: "" findStrategy()?.tfToken?.apply { text = toSelect?.token ?: "" } findStrategy()?.apply { - tfClientCert.text = toSelect?.clientCertData ?: "" - tfClientKey.text = toSelect?.clientKeyData ?: "" + tfClientCert.text = toSelect?.clientCert?.value ?: "" + tfClientKey.text = toSelect?.clientKey?.value ?: "" } } 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 42f59c06..e1b1e121 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 @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.platform.util.progress.RawProgressReporter +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 @@ -51,13 +52,21 @@ abstract class AbstractAuthenticationStrategy( tlsContext: TlsContext, errorMessage: String? = null ): ApiClient = try { + val caSource = certificateAuthorityData?.let { CertificateSource.fromPathOrPem(it) } + val certSource = clientCertPem?.let { CertificateSource.fromPathOrPem(it) } + val keySource = clientKeyPem?.let { CertificateSource.fromPathOrPem(it) } + + caSource?.validate() + certSource?.validate() + keySource?.validate() + OpenShiftClientFactory(KubeConfigUtils) .create( server, - certificateAuthorityData?.toCharArray(), + caSource, token?.toCharArray(), - clientCertPem?.toCharArray(), - clientKeyPem?.toCharArray(), + certSource, + keySource, tlsContext ) .also { client -> 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 59c9c574..51895cf0 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 @@ -18,6 +18,7 @@ import com.intellij.ui.dsl.builder.panel import javax.swing.event.DocumentListener import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.auth.tls.browseCertificate import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster @@ -54,10 +55,22 @@ class ClientCertificateAuthenticationStrategy( override fun createPanel(): JPanel = panel { row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_certificate")) { - cell(tfClientCert).align(Align.FILL) + cell(tfClientCert) + .align(Align.FILL) + .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfClientCert, "Select Client Certificate File") + } } row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_key")) { - cell(tfClientKey).align(Align.FILL) + cell(tfClientKey) + .align(Align.FILL) + .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfClientKey, "Select Client Key File") + } } } diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 448b5532..dafe4052 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -13,9 +13,9 @@ connector.wizard_step.openshift_connection.tab.credentials=Username / Password connector.wizard_step.openshift_connection.tab.redhat_sso=Red Hat SSO connector.wizard_step.openshift_connection.label.token=Token: connector.wizard_step.openshift_connection.label.advanced_group=Advanced Properties -connector.wizard_step.openshift_connection.label.certificate_authority=Certificate Authority (PEM): -connector.wizard_step.openshift_connection.label.client_certificate=Client Certificate (PEM): -connector.wizard_step.openshift_connection.label.client_key=Client Key (PEM): +connector.wizard_step.openshift_connection.label.certificate_authority=Certificate Authority: +connector.wizard_step.openshift_connection.label.client_certificate=Client Certificate: +connector.wizard_step.openshift_connection.label.client_key=Client Key: connector.wizard_step.openshift_connection.label.username=Username: connector.wizard_step.openshift_connection.label.password=Password: connector.wizard_step.openshift_connection.checkbox.save_configuration=Save configuration diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt new file mode 100644 index 00000000..b53b4de9 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt @@ -0,0 +1,282 @@ +/* + * 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.auth.tls + +import org.junit.jupiter.api.Test +import org.assertj.core.api.Assertions.* +import java.nio.file.Paths + +class CertificateSourceTest { + + @Test + fun `#fromData creates source with isFilePath false`() { + // given + // when + val source = CertificateSource.fromData("c29tZS1kYXRh") + + // then + assertThat(source.value).isEqualTo("c29tZS1kYXRh") + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isFalse() + } + + @Test + fun `#fromPath creates source with isFilePath true`() { + // given + // when + val source = CertificateSource.fromPath("/home/user/cert.pem") + + // then + assertThat(source.value).isEqualTo("/home/user/cert.pem") + assertThat(source.isFilePath).isTrue() + assertThat(source.isModified).isFalse() + } + + @Test + fun `#fromPathOrPem detects absolute Unix path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("/home/user/.minikube/ca.crt") + + // then + assertThat(source.isFilePath).isTrue() + assertThat(source.value).isEqualTo("/home/user/.minikube/ca.crt") + } + + @Test + fun `#fromPathOrPem detects home path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("~/.kube/config") + + // then + assertThat(source.isFilePath).isTrue() + assertThat(source.value).startsWith(System.getProperty("user.home")) + } + + @Test + fun `#fromPathOrPem detects relative path starting with dot`() { + // given + // when + val source = CertificateSource.fromPathOrPem("./certs/ca.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects parent relative path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("../shared/cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects Windows path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("C:\\Users\\user\\cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects simple filename`() { + // given + // when + val source = CertificateSource.fromPathOrPem("cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects filename without extension`() { + // given + // when + val source = CertificateSource.fromPathOrPem("mycert") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects filename with hyphens and underscores`() { + // given + // when + val source = CertificateSource.fromPathOrPem("my-cert_file.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects PEM content`() { + // given + // when + val source = CertificateSource.fromPathOrPem("-----BEGIN CERTIFICATE-----") + + // then + assertThat(source.isFilePath).isFalse() + } + + @Test + fun `#fromPathOrPem detects long base64 string`() { + // given + val base64 = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURhekNDQWxPZ0F3SUJBZ0lVWnR4" + + // when + val source = CertificateSource.fromPathOrPem(base64) + + // then + assertThat(source.isFilePath).isFalse() + } + + @Test + fun `#fromPathOrPem marks input as modified`() { + // given + // when + val source = CertificateSource.fromPathOrPem("/path/to/cert") + + // then + assertThat(source.isModified).isTrue() + } + + @Test + fun `#fromPathOrPem trims whitespace`() { + // given + // when + val source = CertificateSource.fromPathOrPem(" /path/to/cert ") + + // then + assertThat(source.value).isEqualTo("/path/to/cert") + } + + @Test + fun `#asModified creates copy with isModified true`() { + // given + val original = CertificateSource.fromPath("/path/cert") + assertThat(original.isModified).isFalse() + + // when + val modified = original.asModified() + + // then + assertThat(modified.isModified).isTrue() + assertThat(original.isModified).isFalse() + } + + @Test + fun `#validate succeeds for base64 data`() { + // given + val source = CertificateSource.fromData("LS0tLS1CRUdJTi") + + // when/then + assertThatCode { source.validate() }.doesNotThrowAnyException() + } + + @Test + fun `#validate succeeds for existing file`() { + // given + val tempFile = java.io.File.createTempFile("test-cert", ".pem") + try { + val source = CertificateSource.fromPath(tempFile.absolutePath) + + // when + // then + assertThatCode { source.validate() }.doesNotThrowAnyException() + } finally { + tempFile.delete() + } + } + + @Test + fun `#validate throws for non-existent file`() { + // given + val source = CertificateSource.fromPath("/non/existent/cert.pem") + + // when + // then + assertThatThrownBy { source.validate() } + .isInstanceOf(java.io.FileNotFoundException::class.java) + .hasMessageContaining("/non/existent/cert.pem") + } + + @Test + fun `#fromUserInput distinguishes short alphanumeric from base64`() { + // given + // when + val source = CertificateSource.fromPathOrPem("cert") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#toPath returns correct Path`() { + // given + val source = CertificateSource.fromPath("/home/user/cert.pem") + + // when + val path = source.toPath() + + // then + assertThat(path).isEqualTo(Paths.get("/home/user/cert.pem")) + } + + @Test + fun `#fromUserInput normalizes and base64-encodes single-line PEM`() { + // given - PEM with newlines stripped (from JBTextField paste) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIBkTCB+wIJAKHHCgVZU6T+MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwfnQ2wLuONsN9Bvj/YqYF7S2VKvwHJRwqLlqgP3uP7F3kMmZQF7VJQ/8qvwzBLvhM5Y3yLVZRZPZ8qYa/QIDAQABo1AwTjAdBgNVHQ4EFgQUqgQKqgQKqgQKqgQKqgQKqgQKMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUqgQKqgQKqgQKqgQKqgQKqgQKMA0GCSqGSIb3DQEBCwUAA4GBALiT-----END CERTIFICATE-----" + + // when + val source = CertificateSource.fromPathOrPem(singleLinePem) + + // then + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isTrue() + // Value should be base64-encoded (not raw PEM) + assertThat(source.value).doesNotContain("-----BEGIN") + assertThat(source.value).doesNotContain("-----END") + // Should be valid base64 + assertThat(source.value).matches("^[A-Za-z0-9+/=\\s]+$") + } + + @Test + fun `#fromUserInput handles properly formatted multi-line PEM`() { + // given - proper PEM with newlines + val multiLinePem = """ + -----BEGIN CERTIFICATE----- + MIIBkTCB+wIJAKHHCgVZU6T+MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv + Y2FsaG9zdDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBaMBQxEjAQBgNV + BAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwfnQ2wLu + ONsN9Bvj/YqYF7S2VKvwHJRwqLlqgP3uP7F3kMmZQF7VJQ/8qvwzBLvhM5Y3yLVZ + RZPZ8qYa/QIDAQABo1AwTjAdBgNVHQ4EFgQUqgQKqgQKqgQKqgQKqgQKqgQKMA8G + A1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUqgQKqgQKqgQKqgQKqgQKqgQKMA0G + CSqGSIb3DQEBCwUAA4GBALiT + -----END CERTIFICATE----- + """.trimIndent() + + // when + val source = CertificateSource.fromPathOrPem(multiLinePem) + + // then + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isTrue() + // Value should be base64-encoded + assertThat(source.value).doesNotContain("-----BEGIN") + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt new file mode 100644 index 00000000..7fa599c2 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt @@ -0,0 +1,154 @@ +/* + * 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 + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.Base64 + +class PemUtilsTest { + + @Test + fun `#toBase64 encodes PEM content`() { + val pem = "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUZtx\n-----END CERTIFICATE-----" + + val result = PemUtils.toBase64(pem) + + assertThat(result).isEqualTo(Base64.getEncoder().encodeToString(pem.toByteArray())) + } + + @Test + fun `#toBase64 passes through already-base64 content`() { + val base64 = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURhekNDQWxP" + + val result = PemUtils.toBase64(base64) + + assertThat(result).isEqualTo(base64) + } + + @Test + fun `#toBase64 is idempotent`() { + val pem = "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUZtx\n-----END CERTIFICATE-----" + + val encoded = PemUtils.toBase64(pem) + val doubleEncoded = PemUtils.toBase64(encoded) + + assertThat(doubleEncoded).isEqualTo(encoded) + } + + @Test + fun `#isPem returns true for PEM content`() { + assertThat(PemUtils.isPem("-----BEGIN CERTIFICATE-----\ndata")).isTrue() + } + + @Test + fun `#isPem returns true for PEM embedded in other text`() { + assertThat(PemUtils.isPem("some prefix -----BEGIN CERTIFICATE-----")).isTrue() + } + + @Test + fun `#isPem returns false for base64 content`() { + assertThat(PemUtils.isPem("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t")).isFalse() + } + + @Test + fun `#isPem returns false for plain text`() { + assertThat(PemUtils.isPem("just some random text")).isFalse() + } + + @Test + fun `#isPem returns false for empty string`() { + assertThat(PemUtils.isPem("")).isFalse() + } + + @Test + fun `#parseCertificate handles single-line PEM from JBTextField paste`() { + // given - simulates pasting multi-line PEM into single-line JBTextField + // (newlines get stripped, resulting in single-line PEM) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(singleLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=sigstore") + assertThat(certificate.issuerX500Principal.name).contains("CN=sigstore") + } + + @Test + fun `#parseCertificate handles properly formatted multi-line PEM`() { + // given - proper PEM with newlines + val multiLinePem = """ + -----BEGIN CERTIFICATE----- + MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw + KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y + MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl + LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 + XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex + X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j + YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY + wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ + KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM + WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 + TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ + -----END CERTIFICATE----- + """.trimIndent() + + // when + val certificate = PemUtils.parseCertificate(multiLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=sigstore") + assertThat(certificate.issuerX500Principal.name).contains("CN=sigstore") + } + + @Test + fun `#parseCertificate handles single-line client certificate`() { + // given - client certificate pasted into JBTextField (newlines stripped) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIDGjCCAgKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2NhbGhvc3QtY2FAMTUzMTQ2ODA4NTAgFw0xODA3MTMwNjQ4MDVaGA8yMTE4MDYxOTA2NDgwNVowHzEdMBsGA1UEAwwUbG9jYWxob3N0QDE1MzE0NjgwODYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC9Qfx1YAEp+wrSIjbinWw3pWIDbf57LutfXgS84ilZpc7M2zeu1QrPyhCedL/gPP0QxKbPS6AR5R/DibH4RWcujL6CU5FB0Y9on+IpN/Iml2XzgGiU82gTkJg185VgWwDaHOPKvUF9N1GpvxcSvRsNGoiBJ/LlE4NhxyUQ0V/lAalYxYybxgl8/xghWMkGnQc3YKWKqGmtBaaax3xvMzamxpWPphoLG07+YZfAf0Q7vslVMmlslRmx9OpJFvRnkelbXoHHx73umbMiFp28njY8NK2dqXwb6Z80BCezppCKYpbjnupOIDAAE0KvjzhhzSS68ZgukiBZOcUlnWLzL39AgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMCUGA1UdEQQeMByCCWxvY2FsaG9zdIIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQBm9Z15QxsRqoaRDh/ELA93eE9105gwrXrR3AK/iKuJyxIc/SXVbpAaYHArMrUzaZs0GXEzgW31tZn8D3dgFy8XdZxk1ztaFTm+QTnFRogMNB8Akvpq7jwTa44c7G0wuNO2nATMu2Ifi/nSdQadTxzmZacSrevN/zcjmvSoV4VFkKO5VBnr7e1ruffxAaVAzrRraplpZvuJzlcvqTYAME8fq8H9QidvaXF6yIbEPwwUHK678W4rXn9Zp6NDuQhH0eNPAGlEAaYuyCJvJZeM68ootMi7Uh6RJOTDw1HIdekYEr1/FAg4+rH/9Gi+o/LXsBmYXabO+GjsOwfezv3THDnw-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(singleLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=localhost@1531468086") + } + + @Test + fun `#parsePrivateKey handles single-line RSA private key from JBTextField paste`() { + // given - 512-bit RSA private key in single-line format (simulating JBTextField paste) + val singleLineKey = "-----BEGIN PRIVATE KEY-----MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAUEwggE9AgEAAkEA8ZF6nT2T9jLuZE6c8CduV0pc0MpCzZkLGKH4KMDjd9J5L/c/DqbrqqubGCfZhWfpn7Dccy1QEVmJf5RgLbxL6QIDAQABAkEAkphhW2jSENdJmi+mx4p2SJzFBKOptJEKjdFFAp5DrCMtOf0SgGPqadEJmVF+FcsFJKi0RXN+0Hk0JIXUoRBoXQIhAPmYugieyFBgcO17xEfe+Bm1rIBMg0DGVQVaxZmJ1LqXAiEA98QGWSQ6OTuR0U0KkgjWEyfRdtf5rOmTk+I2VL2ofX8CIQCCtQg3G2+rJ9X7h6TyPkGOtSTwyyCw+yvq8e4oyZUtYQIhAMZnMq4vVHCAQ0RXbR+D8+li+Vkxmb3dTVAe1WMGfOYBAiEA1axQLnkgglVgK9jGYUgH320TpEwLuOKQCwysxqVg9jQ=-----END PRIVATE KEY-----" + + // when + val privateKey = PemUtils.parsePrivateKey(singleLineKey) + + // then + assertThat(privateKey).isNotNull() + assertThat(privateKey.algorithm).isEqualTo("RSA") + } + + @Test + fun `#parseCertificate handles malformed PEM with only header newline`() { + // given - JBTextField sometimes preserves first newline only + val malformedPem = "-----BEGIN CERTIFICATE-----\nMIIDGjCCAgKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2NhbGhvc3QtY2FAMTUzMTQ2ODA4NTAgFw0xODA3MTMwNjQ4MDVaGA8yMTE4MDYxOTA2NDgwNVowHzEdMBsGA1UEAwwUbG9jYWxob3N0QDE1MzE0NjgwODYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC9Qfx1YAEp+wrSIjbinWw3pWIDbf57LutfXgS84ilZpc7M2zeu1QrPyhCedL/gPP0QxKbPS6AR5R/DibH4RWcujL6CU5FB0Y9on+IpN/Iml2XzgGiU82gTkJg185VgWwDaHOPKvUF9N1GpvxcSvRsNGoiBJ/LlE4NhxyUQ0V/lAalYxYybxgl8/xghWMkGnQc3YKWKqGmtBaaax3xvMzamxpWPphoLG07+YZfAf0Q7vslVMmlslRmx9OpJFvRnkelbXoHHx73umbMiFp28njY8NK2dqXwb6Z80BCezppCKYpbjnupOIDAAE0KvjzhhzSS68ZgukiBZOcUlnWLzL39AgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMCUGA1UdEQQeMByCCWxvY2FsaG9zdIIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQBm9Z15QxsRqoaRDh/ELA93eE9105gwrXrR3AK/iKuJyxIc/SXVbpAaYHArMrUzaZs0GXEzgW31tZn8D3dgFy8XdZxk1ztaFTm+QTnFRogMNB8Akvpq7jwTa44c7G0wuNO2nATMu2Ifi/nSdQadTxzmZacSrevN/zcjmvSoV4VFkKO5VBnr7e1ruffxAaVAzrRraplpZvuJzlcvqTYAME8fq8H9QidvaXF6yIbEPwwUHK678W4rXn9Zp6NDuQhH0eNPAGlEAaYuyCJvJZeM68ootMi7Uh6RJOTDw1HIdekYEr1/FAg4+rH/9Gi+o/LXsBmYXabO+GjsOwfezv3THDnw-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(malformedPem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=localhost@1531468086") + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt index 9429b811..dfdbd6cb 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -30,10 +31,25 @@ class KubeConfigClusterTest { // then assertThat(cluster).isNotNull assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") - assertThat(cluster?.certificateAuthorityData).isEqualTo("LS0tLS1CRUdJTi...") + assertThat(cluster?.certificateAuthority?.value).isEqualTo("LS0tLS1CRUdJTi...") + assertThat(cluster?.certificateAuthority?.isFilePath).isFalse() assertThat(cluster?.insecureSkipTlsVerify).isTrue() } + @Test + fun `#fromMap parses cluster with certificate-authority path`() { + val map = mapOf( + "server" to "https://api.example.com:6443", + "certificate-authority" to "/home/user/.minikube/ca.crt" + ) + + val cluster = KubeConfigCluster.fromMap(map) + + assertThat(cluster).isNotNull + assertThat(cluster?.certificateAuthority?.value).isEqualTo("/home/user/.minikube/ca.crt") + assertThat(cluster?.certificateAuthority?.isFilePath).isTrue() + } + @Test fun `#fromMap is parsing cluster with only server`() { // given @@ -47,7 +63,7 @@ class KubeConfigClusterTest { // then assertThat(cluster).isNotNull assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") - assertThat(cluster?.certificateAuthorityData).isNull() + assertThat(cluster?.certificateAuthority).isNull() assertThat(cluster?.insecureSkipTlsVerify).isNull() } @@ -112,7 +128,7 @@ class KubeConfigClusterTest { // given val cluster = KubeConfigCluster( server = "https://tatooine.starwars.galaxy:6443", - certificateAuthorityData = "LS0tLS1CRUdJTi1MSUdIVF..." /* A long time ago in a galaxy far, far away... */, + certificateAuthority = CertificateSource.fromData("LS0tLS1CRUdJTi1MSUdIVF..."), insecureSkipTlsVerify = true ) @@ -127,6 +143,21 @@ class KubeConfigClusterTest { .containsEntry("insecure-skip-tls-verify", true) } + @Test + fun `#toMap writes certificate-authority path when isFilePath is true`() { + val cluster = KubeConfigCluster( + server = "https://tatooine.starwars.galaxy:6443", + certificateAuthority = CertificateSource.fromPath("/home/user/.minikube/ca.crt") + ) + + val map = cluster.toMap() + + assertThat(map) + .hasSize(2) + .containsEntry("server", "https://tatooine.starwars.galaxy:6443") + .containsEntry("certificate-authority", "/home/user/.minikube/ca.crt") + } + @Test fun `#toMap returns map with only server`() { // given @@ -142,4 +173,4 @@ class KubeConfigClusterTest { .hasSize(1) .containsEntry("server", "https://endor.starwars.galaxy:6443") } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt index 2edd861e..1fd86514 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -9,6 +9,8 @@ * Contributors: * Red Hat, Inc. - initial API and implementation */ +package com.redhat.devtools.gateway.kubeconfig + import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt index 1c349075..151b7932 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -28,8 +29,8 @@ class KubeConfigUserTest { // then assertThat(user.token).isEqualTo("my-secret-token") - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -50,8 +51,10 @@ class KubeConfigUserTest { // then assertThat(user.token).isEqualTo("my-secret-token") - assertThat(user.clientCertificateData).isEqualTo("cert-data") - assertThat(user.clientKeyData).isEqualTo("key-data") + assertThat(user.clientCertificate?.value).isEqualTo("cert-data") + assertThat(user.clientCertificate?.isFilePath).isFalse() + assertThat(user.clientKey?.value).isEqualTo("key-data") + assertThat(user.clientKey?.isFilePath).isFalse() assertThat(user.username).isEqualTo("admin") assertThat(user.password).isEqualTo("secret") } @@ -59,14 +62,14 @@ class KubeConfigUserTest { @Test fun `#fromMap returns empty user for empty map`() { // given - // empty map + val user = KubeConfigUser.fromMap(emptyMap()) // when - val user = KubeConfigUser.fromMap(emptyMap()) + // then assertThat(user.token).isNull() - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -75,19 +78,20 @@ class KubeConfigUserTest { fun `#fromMap is handling non-string values gracefully`() { // given val map = mapOf( - "token" to 12345, // non-string - "client-certificate-data" to listOf("not", "string"), // non-string - "client-key-data" to true, // non-string - "username" to mapOf("not" to "string"), // non-string - "password" to 3.14 // non-string + "token" to 12345, + "client-certificate-data" to listOf("not", "string"), + "client-key-data" to true, + "username" to mapOf("not" to "string"), + "password" to 3.14 ) + // when val user = KubeConfigUser.fromMap(map) - // All should be null since they're not strings + // then assertThat(user.token).isNull() - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -97,8 +101,8 @@ class KubeConfigUserTest { // given val user = KubeConfigUser( token = "DeathStar-token", - clientCertificateData = "Vader-cert", - clientKeyData = "Vader-key", + clientCertificate = CertificateSource.fromData("Vader-cert"), + clientKey = CertificateSource.fromData("Vader-key"), username = "DarthVader", password = "DarkSide" ) @@ -116,6 +120,24 @@ class KubeConfigUserTest { .containsEntry("password", "DarkSide") } + @Test + fun `#toMap returns map with certificate-authority path`() { + // given + val user = KubeConfigUser( + clientCertificate = CertificateSource.fromPath("/home/user/client.crt"), + clientKey = CertificateSource.fromPath("/home/user/client.key") + ) + + // when + val map = user.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("client-certificate", "/home/user/client.crt") + .containsEntry("client-key", "/home/user/client.key") + } + @Test fun `#toMap returns map with only token`() { // given @@ -131,4 +153,4 @@ class KubeConfigUserTest { .hasSize(1) .containsEntry("token", "Rebel-Alliance-Token") } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt index 5923ccec..0d98ba41 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.junit.jupiter.api.Test import org.assertj.core.api.Assertions.* @@ -19,42 +20,27 @@ class ClusterTest { @Test fun `#Cluster constructor creates instance with name, url, and token`() { // given - val name = "death-star" - val url = "https://api.deathstar.empire" - val token = "empire-token-4ls" - // when - val cluster = Cluster(name = name, url = url, token = token) - + val cluster = Cluster(name = "death-star", url = "https://api.deathstar.empire", token = "empire-token-4ls") + // then - assertThat(cluster.name) - .isEqualTo(name) - assertThat(cluster.url) - .isEqualTo(url) - assertThat(cluster.token) - .isEqualTo(token) - assertThat(cluster.id) - .isEqualTo("death-star@api.deathstar.empire") + assertThat(cluster.name).isEqualTo("death-star") + assertThat(cluster.url).isEqualTo("https://api.deathstar.empire") + assertThat(cluster.token).isEqualTo("empire-token-4ls") + assertThat(cluster.id).isEqualTo("death-star@api.deathstar.empire") } @Test fun `#Cluster constructor creates instance with default null token`() { // given - val name = "tatooine" - val url = "https://api.tatooine.galaxy" - // when - val cluster = Cluster(name = name, url = url) - + val cluster = Cluster(name = "tatooine", url = "https://api.tatooine.galaxy") + // then - assertThat(cluster.name) - .isEqualTo(name) - assertThat(cluster.url) - .isEqualTo(url) - assertThat(cluster.token) - .isNull() - assertThat(cluster.id) - .isEqualTo("tatooine@api.tatooine.galaxy") + assertThat(cluster.name).isEqualTo("tatooine") + assertThat(cluster.url).isEqualTo("https://api.tatooine.galaxy") + assertThat(cluster.token).isNull() + assertThat(cluster.id).isEqualTo("tatooine@api.tatooine.galaxy") } @Test @@ -67,106 +53,83 @@ class ClusterTest { token = "solo-token-123") // then - assertThat(cluster.toString()) - .isEqualTo("millennium-falcon (https://api.falcon.ship)") + assertThat(cluster.toString()).isEqualTo("millennium-falcon (https://api.falcon.ship)") } @Test fun `#id property returns formatted id removing protocol`() { - val cluster1 = Cluster(name = "x-wing", url = "https://api.xwing.rebel") - assertThat(cluster1.id) + // given + // when + assertThat(Cluster(name = "x-wing", url = "https://api.xwing.rebel").id) .isEqualTo("x-wing@api.xwing.rebel") - - val cluster2 = Cluster("tie-fighter", "http://api.tie.empire") - assertThat(cluster2.id) + assertThat(Cluster("tie-fighter", "http://api.tie.empire").id) .isEqualTo("tie-fighter@api.tie.empire") - - val cluster3 = Cluster("star-destroyer", "https://api.destroyer.empire:8443") - assertThat(cluster3.id) + assertThat(Cluster("star-destroyer", "https://api.destroyer.empire:8443").id) .isEqualTo("star-destroyer@api.destroyer.empire:8443") - - val cluster4 = Cluster("jedi-council", "https://api.jedi.temple/path") - assertThat(cluster4.id) + assertThat(Cluster("jedi-council", "https://api.jedi.temple/path").id) .isEqualTo("jedi-council@api.jedi.temple/path") } @Test fun `#equals returns true for clusters with same properties`() { // given + // when val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") val cluster3 = Cluster(name = "c3po", url = "https://api.robots.galaxy", token = "droid-token-1") - - // when & then - assertThat(cluster1) - .isEqualTo(cluster2) - assertThat(cluster1) - .isNotEqualTo(cluster3) + + // then + assertThat(cluster1).isEqualTo(cluster2) + assertThat(cluster1).isNotEqualTo(cluster3) } @Test fun `#hashCode returns same value for clusters with same properties`() { // given + // when val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") - - // when & then - assertThat(cluster1.hashCode()) - .isEqualTo(cluster2.hashCode()) + + // then + assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) } @Test fun `#copy method creates new instance with modified properties`() { // given val original = Cluster(name = "obi-wan", url = "https://api.kenobi.jedi", token = "kenobi-token-1") - + // when val copy = original.copy(name = "ben-kenobi") - + // then - assertThat(copy.name) - .isEqualTo("ben-kenobi") - assertThat(copy.url) - .isEqualTo("https://api.kenobi.jedi") - assertThat(copy.token) - .isEqualTo("kenobi-token-1") + assertThat(copy.name).isEqualTo("ben-kenobi") + assertThat(copy.url).isEqualTo("https://api.kenobi.jedi") + assertThat(copy.token).isEqualTo("kenobi-token-1") } @Test fun `#name returns url without scheme nor path`() { - val cluster1 = Cluster.fromNameAndUrl("https://jedi-temple.galaxy") - assertThat(cluster1?.name) - .isEqualTo("jedi-temple.galaxy") - - val cluster2 = Cluster.fromNameAndUrl("http://local-transport:8080") - assertThat(cluster2?.name) - .isEqualTo("local-transport-8080") - - val cluster3 = Cluster.fromNameAndUrl("https://rebel-base.galaxy:443/") - assertThat(cluster3?.name) - .isEqualTo("rebel-base.galaxy-443") - - val cluster4 = Cluster.fromNameAndUrl("https://sith-tower.galaxy:9090/api/v1") - assertThat(cluster4?.name) - .isEqualTo("sith-tower.galaxy-9090") + // given + // when + // then + assertThat(Cluster.fromNameAndUrl("https://jedi-temple.galaxy")?.name).isEqualTo("jedi-temple.galaxy") + assertThat(Cluster.fromNameAndUrl("http://local-transport:8080")?.name).isEqualTo("local-transport-8080") + assertThat(Cluster.fromNameAndUrl("https://rebel-base.galaxy:443/")?.name).isEqualTo("rebel-base.galaxy-443") + assertThat(Cluster.fromNameAndUrl("https://sith-tower.galaxy:9090/api/v1")?.name).isEqualTo("sith-tower.galaxy-9090") } @Test fun `#id returns name@url without scheme, port nor path`() { - val cluster1 = Cluster(name = "x-wing", url = "https://api.xwing.rebel") - assertThat(cluster1.id) + // given + // when + assertThat(Cluster(name = "x-wing", url = "https://api.xwing.rebel").id) .isEqualTo("x-wing@api.xwing.rebel") - - val cluster2 = Cluster("tie-fighter", "http://localhost:8080") - assertThat(cluster2.id) + assertThat(Cluster("tie-fighter", "http://localhost:8080").id) .isEqualTo("tie-fighter@localhost:8080") - - val cluster3 = Cluster("star-destroyer", "https://api.destroyer.empire:443/") - assertThat(cluster3.id) + assertThat(Cluster("star-destroyer", "https://api.destroyer.empire:443/").id) .isEqualTo("star-destroyer@api.destroyer.empire:443/") - - val cluster4 = Cluster("jedi-council", "https://api.jedi.temple:9090/api/v1") - assertThat(cluster4.id) + assertThat(Cluster("jedi-council", "https://api.jedi.temple:9090/api/v1").id) .isEqualTo("jedi-council@api.jedi.temple:9090/api/v1") } @@ -179,13 +142,9 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(url) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.url) - .isEqualTo(url) - assertThat(cluster?.name) - .isNotNull() - .isNotEmpty() + assertThat(cluster).isNotNull() + assertThat(cluster?.url).isEqualTo(url) + assertThat(cluster?.name).isNotNull().isNotEmpty() } @Test @@ -199,12 +158,9 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(input) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.name) - .isEqualTo(name) - assertThat(cluster?.url) - .isEqualTo(url) + assertThat(cluster).isNotNull() + assertThat(cluster?.name).isEqualTo(name) + assertThat(cluster?.url).isEqualTo(url) } @Test @@ -216,10 +172,8 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(url) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.url) - .isEqualTo(url) + assertThat(cluster).isNotNull() + assertThat(cluster?.url).isEqualTo(url) } @Test @@ -227,95 +181,103 @@ class ClusterTest { // given val name = "yavin" val url = "https://api.yavin.rebel" - val cert = "cert-data" - val key = "key-data" // when val cluster = Cluster( name = name, url = url, - clientCertData = cert, - clientKeyData = key + clientCert = CertificateSource.fromData("cert-data"), + clientKey = CertificateSource.fromData("key-data") ) // then assertThat(cluster.name).isEqualTo(name) assertThat(cluster.url).isEqualTo(url) assertThat(cluster.token).isNull() - assertThat(cluster.clientCertData).isEqualTo(cert) - assertThat(cluster.clientKeyData).isEqualTo(key) + assertThat(cluster.clientCert?.value).isEqualTo("cert-data") + assertThat(cluster.clientKey?.value).isEqualTo("key-data") } @Test fun `#Cluster constructor allows token-only authentication`() { + // given + // when val cluster = Cluster( name = "scarif", url = "https://api.scarif.empire", token = "empire-token" ) + // then assertThat(cluster.token).isEqualTo("empire-token") - assertThat(cluster.clientCertData).isNull() - assertThat(cluster.clientKeyData).isNull() + assertThat(cluster.clientCert).isNull() + assertThat(cluster.clientKey).isNull() } @Test fun `#Cluster constructor fails when both token and client certificate are provided`() { + // given + // when assertThatThrownBy { Cluster( name = "mustafar", url = "https://api.mustafar.sith", token = "vader-token", - clientCertData = "cert", - clientKeyData = "key" + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") ) }.isInstanceOf(IllegalArgumentException::class.java) } @Test fun `#Cluster constructor fails when certificate is provided without key`() { + // given + // when assertThatThrownBy { Cluster( name = "kamino", url = "https://api.kamino.cloners", - clientCertData = "cert-only" + clientCert = CertificateSource.fromData("cert-only") ) }.isInstanceOf(IllegalArgumentException::class.java) } @Test fun `#Cluster constructor fails when key is provided without certificate`() { + // given + // when assertThatThrownBy { Cluster( name = "geonosis", url = "https://api.geonosis.droids", - clientKeyData = "key-only" + clientKey = CertificateSource.fromData("key-only") ) }.isInstanceOf(IllegalArgumentException::class.java) } @Test fun `#equals and hashCode include client certificate fields`() { + // given val cluster1 = Cluster( name = "endor", url = "https://api.endor.rebel", - clientCertData = "cert", - clientKeyData = "key" + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") ) - val cluster2 = Cluster( name = "endor", url = "https://api.endor.rebel", - clientCertData = "cert", - clientKeyData = "key" + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") ) - val cluster3 = Cluster( name = "endor", url = "https://api.endor.rebel", token = "ewok-token" ) + // when + // then assertThat(cluster1).isEqualTo(cluster2) assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) assertThat(cluster1).isNotEqualTo(cluster3) From 68ad20bffe3317608de1864d725587659a5c3d80 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 5 May 2026 00:25:47 +0200 Subject: [PATCH 31/32] fix: corrected use of internal API, fixing plugin verification Signed-off-by: Andre Dietisheim --- .../view/steps/DevSpacesServerStepView.kt | 32 ++++++++++--------- .../auth/AbstractAuthenticationStrategy.kt | 5 ++- .../view/steps/auth/AuthenticationStrategy.kt | 7 ++-- ...ClientCertificateAuthenticationStrategy.kt | 13 ++++---- ...nShiftCredentialsAuthenticationStrategy.kt | 13 ++++---- .../OpenShiftOAuthAuthenticationStrategy.kt | 17 +++++----- .../auth/RedHatSSOAuthenticationStrategy.kt | 17 +++++----- .../steps/auth/TokenAuthenticationStrategy.kt | 11 +++---- 8 files changed, 55 insertions(+), 60 deletions(-) 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 08626a43..495daa6d 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,12 +16,10 @@ 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.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.platform.ide.progress.ModalTaskOwner -import com.intellij.platform.ide.progress.runWithModalProgressBlocking -import com.intellij.platform.util.progress.RawProgressReporter -import com.intellij.platform.util.progress.reportRawProgress import com.redhat.devtools.gateway.auth.tls.browseCertificate import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTabbedPane @@ -358,10 +356,11 @@ class DevSpacesServerStepView( onDispose() try { - runWithModalProgressBlocking(ModalTaskOwner.component(component), "Connecting to OpenShift...") { - withContext(Dispatchers.IO) { - reportRawProgress { reporter -> - reporter.text("Connecting to cluster...") + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking(Dispatchers.IO) { + val indicator = ProgressManager.getInstance().progressIndicator + indicator.text = "Connecting to cluster..." val tlsContext = resolveSslContext(server) val certAuthorityData = tfCertAuthority.text @@ -371,12 +370,15 @@ class DevSpacesServerStepView( server, certAuthorityData, tlsContext, - reporter, + indicator, devSpacesContext ) } - } - } + }, + "Connecting to OpenShift...", + true, + null + ) success = true } catch (e: AuthenticationException) { if (!e.isCancellationException()) { @@ -474,11 +476,11 @@ class DevSpacesServerStepView( ) } - private fun saveKubeconfig(cluster: Cluster?, token: String?, reporter: RawProgressReporter) { + private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { if (!saveToKubeconfig || cluster == null || token.isNullOrBlank()) return try { - reporter.text("Updating Kube config...") + indicator.text = "Updating Kube config..." KubeConfigUpdate .create( cluster.name.trim(), @@ -491,11 +493,11 @@ class DevSpacesServerStepView( } } - private fun saveKubeconfigWithCert(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, reporter: RawProgressReporter) { + private fun saveKubeconfigWithCert(cluster: Cluster?, clientCertPem: String?, clientKeyPem: String?, indicator: ProgressIndicator) { if (!saveToKubeconfig || cluster == null || clientCertPem.isNullOrBlank() || clientKeyPem.isNullOrBlank()) return try { - reporter.text("Updating Kube config...") + indicator.text = "Updating Kube config..." KubeConfigUpdate .create( cluster.name.trim(), diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt index e1b1e121..278ff801 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 @@ -11,7 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps.auth -import com.intellij.platform.util.progress.RawProgressReporter +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 @@ -26,10 +26,9 @@ import io.kubernetes.client.openapi.ApiException * Abstract base class for authentication strategies. * Provides common functionality and access to shared UI components. */ -@Suppress("UnstableApiUsage") abstract class AbstractAuthenticationStrategy( protected val tfServer: Any, // FilteringComboBox - protected val saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit + protected val saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit ) : AuthenticationStrategy { /** 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 a8f73b8e..a6ecdd7d 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 @@ -11,7 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps.auth -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster @@ -47,17 +47,16 @@ interface AuthenticationStrategy { * @param server The server URL * @param certAuthorityData The certificate authority data * @param tlsContext The TLS context for secure connections - * @param reporter The progress reporter + * @param indicator The progress indicator * @param devSpacesContext The DevSpaces context to update * @return true if authentication succeeded, false otherwise */ - @Suppress("UnstableApiUsage") suspend fun authenticate( selectedCluster: Cluster, server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) 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 51895cf0..7ad54989 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 @@ -11,7 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps.auth -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.panel @@ -27,11 +27,10 @@ import javax.swing.JPanel /** * Authentication strategy for client certificate authentication. */ -@Suppress("UnstableApiUsage") class ClientCertificateAuthenticationStrategy( tfServer: Any, - saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, - private val saveKubeconfigWithCert: suspend (Cluster, String, String, RawProgressReporter) -> Unit, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val saveKubeconfigWithCert: suspend (Cluster, String, String, ProgressIndicator) -> Unit, private val onFieldChanged: () -> DocumentListener ) : AbstractAuthenticationStrategy( tfServer, @@ -79,10 +78,10 @@ class ClientCertificateAuthenticationStrategy( server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) { - reporter.text("Validating client certificate...") + indicator.text = "Validating client certificate..." val clientCertPem = tfClientCert.text val clientKeyPem = tfClientKey.text @@ -97,7 +96,7 @@ class ClientCertificateAuthenticationStrategy( "Authentication failed: invalid client certificate or key." ) - saveKubeconfigWithCert(selectedCluster, clientCertPem, clientKeyPem, reporter) + saveKubeconfigWithCert(selectedCluster, clientCertPem, clientKeyPem, indicator) devSpacesContext.client = client } 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 0e346feb..c03616ec 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 @@ -11,7 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps.auth -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextField @@ -34,10 +34,9 @@ import javax.swing.JPanel /** * Authentication strategy for OpenShift credentials (username/password). */ -@Suppress("UnstableApiUsage") class OpenShiftCredentialsAuthenticationStrategy( tfServer: Any, - saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener, private val setTokenDisplay: suspend (String) -> Unit @@ -90,10 +89,10 @@ class OpenShiftCredentialsAuthenticationStrategy( server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) { - reporter.text("Authenticating with OpenShift credentials...") + indicator.text = "Authenticating with OpenShift credentials..." val username = tfUsername.text val password = String(tfPassword.password) @@ -115,7 +114,7 @@ class OpenShiftCredentialsAuthenticationStrategy( clusterApiUrl = selectedCluster.url ) - reporter.text("Validating cluster access...") + indicator.text = "Validating cluster access..." val client = createValidatedApiClient( server, @@ -128,7 +127,7 @@ class OpenShiftCredentialsAuthenticationStrategy( ) setTokenDisplay(finalToken.accessToken) - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) devSpacesContext.client = client } 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 b810d528..1c8d0f3e 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,7 +12,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.ide.BrowserUtil -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.panel @@ -34,10 +34,9 @@ import javax.swing.JPanel /** * Authentication strategy for OpenShift OAuth (browser-based PKCE). */ -@Suppress("UnstableApiUsage") class OpenShiftOAuthAuthenticationStrategy( tfServer: Any, - saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val setTokenDisplay: suspend (String) -> Unit ) : AbstractAuthenticationStrategy( tfServer, @@ -60,10 +59,10 @@ class OpenShiftOAuthAuthenticationStrategy( server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) { - reporter.text("Authenticating with Openshift...") + indicator.text = "Authenticating with Openshift..." val openshiftSSessionManager = OpenShiftAuthSessionManager() val uri = openshiftSSessionManager.startLogin( @@ -74,10 +73,10 @@ class OpenShiftOAuthAuthenticationStrategy( BrowserUtil.browse(uri) } - reporter.text("Waiting for you to complete login in your browser...") + indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() - reporter.text("Obtaining OpenShift access...") + indicator.text = "Obtaining OpenShift access..." val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) val finalToken = TokenModel( @@ -88,7 +87,7 @@ class OpenShiftOAuthAuthenticationStrategy( clusterApiUrl = selectedCluster.url ) - reporter.text("Validating cluster access...") + indicator.text = "Validating cluster access..." val client = createValidatedApiClient( server, @@ -101,7 +100,7 @@ class OpenShiftOAuthAuthenticationStrategy( ) setTokenDisplay(finalToken.accessToken) - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) devSpacesContext.client = client } 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 c1e3a160..303dd63d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -12,7 +12,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.ide.BrowserUtil -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.components.JBPasswordField import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.panel @@ -34,10 +34,9 @@ import javax.swing.JPanel /** * Authentication strategy for Red Hat SSO (Sandbox). */ -@Suppress("UnstableApiUsage") class RedHatSSOAuthenticationStrategy( tfServer: Any, - saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val sessionManager: RedHatAuthSessionManager ) : AbstractAuthenticationStrategy( tfServer, @@ -67,26 +66,26 @@ class RedHatSSOAuthenticationStrategy( server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) { - reporter.text("Authenticating with Red Hat...") + indicator.text = "Authenticating with Red Hat..." val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) withContext(Dispatchers.Main) { BrowserUtil.browse(uri) } - reporter.text("Waiting for you to complete login in your browser...") + indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - reporter.text("Obtaining OpenShift access...") + indicator.text = "Obtaining OpenShift access..." val sandboxAuth = SandboxClusterAuthProvider() val finalToken = sandboxAuth.authenticate(ssoToken) - reporter.text("Validating cluster access...") + indicator.text = "Validating cluster access..." try { val client = createValidatedApiClient( @@ -100,7 +99,7 @@ class RedHatSSOAuthenticationStrategy( // Do not save SSO tokens if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, reporter) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) } devSpacesContext.client = client } catch (e: AuthenticationException) { 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 0c5d9af0..e8a0d86d 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 @@ -13,7 +13,7 @@ package com.redhat.devtools.gateway.view.steps.auth import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState -import com.intellij.platform.util.progress.RawProgressReporter +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.ui.dsl.builder.Align @@ -37,10 +37,9 @@ import javax.swing.JPanel /** * Authentication strategy for token-based authentication. */ -@Suppress("UnstableApiUsage") class TokenAuthenticationStrategy( tfServer: Any, - saveKubeconfig: suspend (Cluster, String, RawProgressReporter) -> Unit, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, private val onFieldChanged: () -> DocumentListener, private val createEnterKeyListener: () -> KeyListener ) : AbstractAuthenticationStrategy( @@ -88,10 +87,10 @@ class TokenAuthenticationStrategy( server: String, certAuthorityData: String, tlsContext: TlsContext, - reporter: RawProgressReporter, + indicator: ProgressIndicator, devSpacesContext: DevSpacesContext ) { - reporter.text("Validating token...") + indicator.text = "Validating token..." val token = String(tfToken.password) @@ -105,7 +104,7 @@ class TokenAuthenticationStrategy( "Authentication failed: invalid server URL or token." ) - saveKubeconfig.invoke(selectedCluster, token, reporter) + saveKubeconfig.invoke(selectedCluster, token, indicator) devSpacesContext.client = client } From 0c49093436bcc9db39f3bb6e78704c781ab9b280 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Tue, 5 May 2026 11:07:04 +0200 Subject: [PATCH 32/32] fix: hovering over 'show token' icon turns mousepointer to hand --- .../redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt index 4b903726..14fd0952 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt @@ -14,6 +14,7 @@ package com.redhat.devtools.gateway.view.ui import com.intellij.icons.AllIcons import com.intellij.ui.components.JBPasswordField import com.intellij.util.ui.JBUI +import java.awt.Cursor import java.awt.Dimension import javax.swing.JButton import javax.swing.JLayeredPane @@ -32,6 +33,7 @@ class PasswordFieldWithToggle : JPanel() { private val toggleButton = JButton().apply { icon = AllIcons.General.InspectionsEye toolTipText = "Show/Hide password" + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) isBorderPainted = false isContentAreaFilled = false isFocusPainted = false