From b2562469f69dc02c0d7dffe03605dda0c55cc1d9 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Tue, 20 Jan 2026 19:20:40 +0100 Subject: [PATCH 1/4] 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 a45f25c9..b0aae782 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:25.0.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.21.1") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.1") + + // 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 5361033528e00d4a325c94c2ae7ff950541675cc Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Wed, 28 Jan 2026 14:12:54 +0100 Subject: [PATCH 2/4] 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 | 40 +- .../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, 2213 insertions(+), 216 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 e899f772..47c77402 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/ @@ -20,19 +20,16 @@ import com.intellij.ui.dsl.builder.panel import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.DevWorkspaces -import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory import com.redhat.devtools.gateway.openshift.isNotFound 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 +45,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( { @@ -64,7 +69,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { indicator.isIndeterminate = true indicator.text = "Connecting to DevSpace..." - val handle = doConnect(parameters, indicator) + val handle = doConnect(ctx, parameters, indicator) val thinClient = handle.clientHandle ?: throw RuntimeException("Failed to obtain ThinClientHandle") @@ -172,6 +177,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { @Suppress("UnstableApiUsage") @Throws(IllegalArgumentException::class) private fun doConnect( + ctx: DevSpacesContext, parameters: Map, indicator: ProgressCountdown ): GatewayConnectionHandle { @@ -191,13 +197,6 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { throw IllegalArgumentException("Query parameter \"$DW_NAME\" is missing") } - val ctx = DevSpacesContext() - - indicator.update(message = "Initializing Kubernetes connection…") - val factory = OpenShiftClientFactory(KubeConfigUtils) - this.clientFactory = factory - ctx.client = factory.create() - indicator.update(message = "Fetching DevWorkspace “$dwName” from namespace “$dwNamespace”…") ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName) @@ -245,12 +244,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 @@ -275,5 +272,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 9106ff8c..ec8a51bf 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -5,12 +5,33 @@ 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 and continue connector.wizard_step.openshift_connection.label.update_kubeconfig_path=Update kubeconfig: {0} connector.wizard_step.openshift_connection.label.update_kubeconfig=Update kubeconfig +# 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 7c54a5ac7b13de16091078f590d4aed3147e5cc6 Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Fri, 6 Mar 2026 01:14:06 +0100 Subject: [PATCH 3/4] 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 4c3e2fb46a3d6370f08620607214708d555efaea Mon Sep 17 00:00:00 2001 From: Victor Rubezhny Date: Fri, 6 Mar 2026 20:24:42 +0100 Subject: [PATCH 4/4] 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) }