diff --git a/.serena/cache/kotlin/document_symbols.pkl b/.serena/cache/kotlin/document_symbols.pkl new file mode 100644 index 00000000..795bc531 Binary files /dev/null and b/.serena/cache/kotlin/document_symbols.pkl differ diff --git a/.serena/cache/kotlin/raw_document_symbols.pkl b/.serena/cache/kotlin/raw_document_symbols.pkl new file mode 100644 index 00000000..2eeb1438 Binary files /dev/null and b/.serena/cache/kotlin/raw_document_symbols.pkl differ diff --git a/.serena/project.local.yml b/.serena/project.local.yml new file mode 100644 index 00000000..36cd3def --- /dev/null +++ b/.serena/project.local.yml @@ -0,0 +1,5 @@ +# This file allows you to locally override settings in project.yml for development purposes. +# +# Use the same keys as in project.yml here. Any setting you specify will override the corresponding +# setting in project.yml, allowing you to customise the configuration for your local development environment +# without affecting the project configuration in project.yml (which is intended to be versioned). diff --git a/build.gradle.kts b/build.gradle.kts index 6894540d..2e2ecf5c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + kotlin("plugin.serialization") version "1.9.22" // Serialization needed for RedHat Auth } group = providers.gradleProperty("pluginGroup").get() @@ -71,6 +72,17 @@ dependencies { implementation("io.kubernetes:client-java:26.0.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.21.3") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.3") + + // RedHat Auth dependencies + implementation("io.ktor:ktor-server-core-jvm:2.3.7") + implementation("io.ktor:ktor-server-netty-jvm:2.3.7") + implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.7") + + implementation("com.nimbusds:oauth2-oidc-sdk:11.15") // Core OIDC/OAuth2 + implementation("com.nimbusds:nimbus-jose-jwt:9.37") // JWT processing + + // JSON serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } // Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt index 6b992489..ea11a7c7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnectionProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * Copyright (c) 2024-2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -28,11 +28,10 @@ import com.redhat.devtools.gateway.openshift.isUnauthorized import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.util.messageWithoutPrefix +import com.redhat.devtools.gateway.view.SelectClusterDialog import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.* import java.util.concurrent.CancellationException import javax.swing.JComponent import javax.swing.Timer @@ -48,14 +47,22 @@ private const val DW_NAME = "dwName" */ class DevSpacesConnectionProvider : GatewayConnectionProvider { - private var clientFactory: OpenShiftClientFactory? = null - @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UnstableApiUsage") override suspend fun connect( parameters: Map, requestor: ConnectionRequestor ): GatewayConnectionHandle? { + val ctx = DevSpacesContext() + + val confirmed = withContext(Dispatchers.Main) { + SelectClusterDialog(ctx).showAndConnect() + } + + if (!confirmed) { + return null + } + return suspendCancellableCoroutine { cont -> ProgressManager.getInstance().runProcessWithProgressSynchronously( { @@ -194,7 +201,6 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { indicator.update(message = "Initializing Kubernetes connection…") val factory = OpenShiftClientFactory(KubeConfigUtils) - this.clientFactory = factory ctx.client = factory.create() indicator.update(message = "Fetching workspace “$dwName” from namespace “$dwNamespace”…") @@ -244,12 +250,10 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { private fun handleUnauthorizedError(err: ApiException): Boolean { if (!err.isUnauthorized()) return false - val tokenNote = if (clientFactory?.isTokenAuth() == true) - "\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file." - else "" - Dialogs.error( - "Your session has expired.\nPlease log in again to continue.$tokenNote", + "Your session has expired.\n" + + "Please authenticate again to continue.\n\n" + + "If you are using token-based authentication, update your token in the kubeconfig file.", "Authentication Required" ) return true @@ -274,5 +278,4 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { runnable.invoke() }.start() } - } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt new file mode 100644 index 00000000..6350fe94 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import java.net.URI +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier +import com.nimbusds.openid.connect.sdk.Nonce +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 +) + +typealias Parameters = Map + +interface AuthCodeFlow { + /** 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 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/IdeaSecureTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt new file mode 100644 index 00000000..c94e3524 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt @@ -0,0 +1,63 @@ +/* + * 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.coroutines.* +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) + + withContext(Dispatchers.IO) { + PasswordSafe.instance.set( + attributes, + Credentials("sso", serialized) + ) + } + } + + override suspend fun loadToken(): TokenModel? { + val credentials = withContext(Dispatchers.IO) { + PasswordSafe.instance.get(attributes) + } ?: return null + + val raw = credentials.password?.toString() + ?: return null + + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } + + override suspend fun clearToken() { + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, null) + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt new file mode 100644 index 00000000..6354f64e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt @@ -0,0 +1,58 @@ +/* + * 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.coroutines.* +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 + ) + + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, credentials) + } + } + + override suspend fun loadToken(): TokenModel? { + val credentials = withContext(Dispatchers.IO) { + PasswordSafe.instance.get(attributes) + } ?: return null + val json = credentials.getPasswordAsString() ?: return null + return Json.decodeFromString(json) + } + + override suspend fun clearToken() { + withContext(Dispatchers.IO) { + PasswordSafe.instance.set(attributes, null) + } + } +} + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt new file mode 100644 index 00000000..8293d4c2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -0,0 +1,292 @@ +/* + * 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 kotlinx.coroutines.* +import kotlinx.coroutines.future.await +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.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`. + * Does NOT require RH SSO token. + */ +class OpenShiftAuthCodeFlow( + private val apiServerUrl: String, // Cluster API server + private val redirectUri: URI?, // Local callback server URI (optional) + private val sslContext: SSLContext +) : AuthCodeFlow { + + private lateinit var codeVerifier: CodeVerifier + private lateinit var state: State + + private lateinit var metadata: OAuthMetadata + + private val json = Json { ignoreUnknownKeys = true } + + private val discoveryClient: HttpClient by lazy { + HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + } + + private val noRedirectClient: HttpClient by lazy { + HttpClient.newBuilder() + .sslContext(sslContext) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + } + + @Serializable + private data class OAuthMetadata( + val issuer: String, + + @SerialName("authorization_endpoint") + val authorizationEndpoint: String, + + @SerialName("token_endpoint") + val tokenEndpoint: String + ) + + /** + * Discover OAuth endpoints from the cluster. + */ + private suspend fun discoverOAuthMetadata(): OAuthMetadata { + val client = discoveryClient + + val request = HttpRequest.newBuilder() + .uri(URI.create("$apiServerUrl/.well-known/oauth-authorization-server")) + .GET() + .build() + + val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() !in 200..299) { + error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") + } + + return json.decodeFromString(OAuthMetadata.serializer(), response.body()) + } + + override suspend fun startAuthFlow(): AuthCodeRequest { + metadata = discoverOAuthMetadata() + 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") + + 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)) + + 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.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + 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 = "", + 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.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() + + // Retry with Basic auth + if (response.statusCode() == 401) { + request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("Authorization", basicAuth) + .header("X-Csrf-Token", "1") + .GET() + .build() + + response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).await() + } + + if (response.statusCode() !in listOf(302, 303)) { + error("Authorization failed: ${response.statusCode()}") + } + + val location = response.headers().firstValue("Location") + .orElseThrow { error("Missing redirect Location header") } + val redirectedUri = URI(location) + val query = redirectedUri.query ?: error("Missing query in redirect") + val params = query.split("&") + .map { it.split("=", limit = 2) } + .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + + val code = params["code"] ?: error("Authorization code not found in redirect") + + val token = exchangeCodeForTokenWithBasicAuth(httpClient, code = code, redirectUri = redirectUri) + + return SSOToken( + accessToken = token.accessToken, + idToken = token.idToken, + accountLabel = username, + expiresAt = token.expiresAt + ) + } + + private suspend fun exchangeCodeForTokenWithBasicAuth( + httpClient: HttpClient, + code: String, + redirectUri: URI + ): SSOToken { + val clientAuth = "Basic " + Base64.getEncoder() + .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) + + val form = encodeForm( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + val request = HttpRequest.newBuilder() + .uri(URI(metadata.tokenEndpoint)) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuth) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + + val response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).await() + if (response.statusCode() != 200) { + error("Token exchange failed: ${response.statusCode()} ${response.body()}") + } + + val token = json.decodeFromString( + AccessTokenResponseJson.serializer(), + response.body() + ) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + + return SSOToken( + accessToken = token.accessToken, + idToken = "", + accountLabel = "", + expiresAt = expiresAt + ) + } +} 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..922c3e86 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -0,0 +1,164 @@ +/* + * 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 kotlinx.coroutines.future.await +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.util.* +import javax.net.ssl.SSLContext + +/** + * 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 by lazy { + 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") + + 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.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ).await() + + if (response.statusCode() !in 200..299) { + error("Token request failed: ${response.statusCode()}\n${response.body()}") + } + + return createSSOToken(response.body()) + } + + private fun createSSOToken(response: String): SSOToken { + val json = Json { ignoreUnknownKeys = true } + val body = json.parseToJsonElement(response).jsonObject + + val accessToken = body["access_token"]?.jsonPrimitive?.content + ?: error("Missing access_token in token response") + + val idToken = body["id_token"]?.jsonPrimitive?.content.orEmpty() + val expiresInSeconds = body["expires_in"]?.jsonPrimitive?.longOrNull ?: 3600 + val accountLabel = createAccountLabel(idToken) + + return SSOToken( + accessToken = accessToken, + idToken = idToken, + expiresAt = System.currentTimeMillis() + expiresInSeconds * 1000, + 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." + ) + + private fun createAccountLabel(idToken: String): String = if (idToken.isNotBlank()) { + try { + val jwt = JWTParser.parse(idToken) as SignedJWT + val claims = jwt.jwtClaimsSet + + claims.getStringClaim("preferred_username") + ?: claims.getStringClaim("email") + ?: "unknown-user" + } catch (e: Exception) { + "unknown-user" + } + } else { + "unknown-user" + } + + private fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" + } + +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/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..a6bb0a08 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt @@ -0,0 +1,40 @@ +/* + * 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +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 = withContext(Dispatchers.IO) { + 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..67f19853 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.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.sandbox + +import kotlinx.coroutines.future.await +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 + } + + suspend fun getSignUpStatus(ssoToken: String): SandboxSignupResponse? { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .GET() + .build() + + val response = httpClient.sendAsync( + request, + HttpResponse.BodyHandlers.ofString() + ).await() + + if (response.statusCode() != 200) { + return null + } + + return json.decodeFromString(response.body()) + } + + suspend fun signUp(ssoToken: String): Boolean { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .POST(HttpRequest.BodyPublishers.noBody()) + .build() + + val response = httpClient.sendAsync( + request, + HttpResponse.BodyHandlers.discarding() + ).await() + + return response.statusCode() in 200..299 + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt new file mode 100644 index 00000000..012e9af6 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt @@ -0,0 +1,120 @@ +/* + * 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 com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.V1ObjectMeta +import io.kubernetes.client.openapi.models.V1Secret +import io.kubernetes.client.openapi.models.V1ServiceAccount +import kotlinx.coroutines.* +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +class SandboxClusterAuthProvider( + private val sandboxApi: SandboxApi = SandboxApi( + SandboxDefaults.SANDBOX_API_BASE_URL, + SandboxDefaults.SANDBOX_API_TIMEOUT_MS + ), + private val clientFactory: OpenShiftClientFactory = OpenShiftClientFactory(KubeConfigUtils) +) { + suspend fun authenticate(ssoToken: SSOToken): TokenModel { + val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) + ?: error("Sandbox not available") + + if (!signup.status.ready) error("Sandbox not ready") + + val username = signup.compliantUsername ?: signup.username + val namespace = "$username-dev" + + val client = clientFactory + .builder(signup.proxyUrl!!, ssoToken.idToken) + .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 suspend fun ensurePipelineServiceAccount(api: CoreV1Api, namespace: String): V1ServiceAccount = withContext(Dispatchers.IO) { + val saList = api.listNamespacedServiceAccount(namespace).execute() + ?: error("Failed to list ServiceAccounts") + + saList.items.firstOrNull { it.metadata?.name == "pipeline" } + ?: api.createNamespacedServiceAccount( + namespace, + V1ServiceAccount().metadata(V1ObjectMeta().name("pipeline")) + ).execute() ?: error("Failed to create pipeline ServiceAccount") + } + + private suspend fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret = withContext(Dispatchers.IO) { + val secretName = "pipeline-secret-${sa.metadata?.name}" + val secretList = api.listNamespacedSecret(namespace).execute() + ?: error("Failed to list Secrets") + + val existing = secretList.items.firstOrNull { it.metadata?.name == secretName } + if (existing != null) { + val populated = requestSecret(secretName, namespace, api) + ?: error("Pipeline token secret not populated") + return@withContext populated + } + + val secret = V1Secret().metadata( + V1ObjectMeta() + .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() + + val populated = requestSecret(secretName, namespace, api) + ?: error("Pipeline token secret not populated") + + return@withContext populated + } + + private suspend fun requestSecret(secretName: String, namespace: String, api: CoreV1Api): V1Secret? { + repeat(30) { + val secret = api.readNamespacedSecret(secretName, namespace).execute() + if (secret.data?.containsKey("token") == true) { + return secret + } + delay(1000.milliseconds) + } + return null + } + + 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..8b5e17c5 --- /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 + +import com.redhat.devtools.gateway.auth.code.Parameters + +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..baa8753a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/OAuthCallbackServer.kt @@ -0,0 +1,76 @@ +/* + * 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.code.Parameters +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/AbstractAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt new file mode 100644 index 00000000..1ea0e8c9 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.session + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.server.CallbackServer +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds + +/** + * Abstract base class for authentication session managers. + * + * Provides common functionality for OAuth-based authentication including: + * - Token storage and retrieval with expiration handling + * - Login state management with concurrent access protection + * - Session listener notifications + * - Callback server lifecycle management + * + * ## Thread Safety + * This class is designed for concurrent access: + * - Login state is protected by [AtomicBoolean] + * - Token access is protected by [Mutex] + * - Listener notifications are fail-safe and copy-on-iterate + * + * ## Subclass Responsibilities + * Implementations must provide: + * - [initialize]: One-time setup on plugin startup + * - [startLogin]: Provider-specific OAuth flow initialization + * - [loginWithCredentials]: Provider-specific credential-based login (or throw UnsupportedOperationException) + * - [callbackServer]: The callback server instance for OAuth flows + * + * @param tokenStorage Storage mechanism for persisting tokens securely. Defaults to [JBPasswordSafeTokenStorage] + */ +abstract class AbstractAuthSessionManager( + protected val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() +) : AuthSessionManager { + + protected abstract val callbackServer: CallbackServer + + private val listeners = mutableSetOf() + private val tokenMutex = Mutex() + private var _currentToken: SSOToken? = null + + protected var currentToken: SSOToken? + get() = _currentToken + set(value) { _currentToken = value } + + protected val loginInProgress = AtomicBoolean(false) + protected var pendingLogin: CompletableDeferred? = null + + /** + * Checks if a login operation is currently in progress. + * + * @return true if login is in progress, false otherwise + */ + fun isLoginInProgress(): Boolean = loginInProgress.get() + + /** + * Adds a session change listener. + * Thread-safe and can be called during listener notification. + * + * @param listener The listener to add + */ + fun addListener(listener: AuthSessionListener) { + synchronized(listeners) { + listeners += listener + } + } + + /** + * Removes a session change listener. + * Thread-safe and can be called during listener notification. + * + * @param listener The listener to remove + */ + fun removeListener(listener: AuthSessionListener) { + synchronized(listeners) { + listeners -= listener + } + } + + /** + * Notifies all registered listeners of a session change. + * Notification failures are logged but do not prevent other listeners from being notified. + */ + protected fun notifyChanged() { + val listenersCopy = synchronized(listeners) { listeners.toList() } + listenersCopy.forEach { listener -> + try { + listener.sessionChanged() + } catch (e: Exception) { + thisLogger().error("Session listener notification failed", e) + } + } + } + + /** + * Awaits the result of a login operation started via [startLogin]. + * + * @param timeoutMs Maximum time to wait in milliseconds + * @return The received SSO token + * @throws IllegalStateException if login was not started + * @throws SsoLoginException.Timeout if the operation times out + */ + override suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") + return try { + val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } + thisLogger().info("Login result received successfully") + token + } catch (e: TimeoutCancellationException) { + thisLogger().warn("Login timed out after ${timeoutMs}ms") + throw SsoLoginException.Timeout() + } + } + + /** + * Cancels the current login operation and cleans up resources. + */ + protected suspend fun cancelLogin() { + thisLogger().debug("Cancelling login") + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + /** + * Returns a valid (non-expired) token or null. + * If the current token is expired, automatically logs out and returns null. + * + * @return A valid token or null if not logged in or token is expired + */ + override suspend fun getValidToken(): SSOToken? = tokenMutex.withLock { + val token = currentToken + if (token == null) { + thisLogger().debug("No current token available") + return null + } + + if (!token.isExpired()) { + thisLogger().debug("Returning valid token for account: ${token.accountLabel}") + return token + } + + thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") + // Call private logout method to avoid deadlock (already holding tokenMutex) + doLogout() + return null + } + + /** + * Logs out the current user by clearing the token from storage and memory. + * Notifies all registered listeners of the session change. + */ + override suspend fun logout() = tokenMutex.withLock { + doLogout() + } + + /** + * Internal logout implementation that doesn't acquire the lock. + * Must be called while holding tokenMutex. + */ + private suspend fun doLogout() { + val account = currentToken?.accountLabel + thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + /** + * Checks if a user is currently logged in. + * + * @return true if a token exists (regardless of expiration), false otherwise + */ + override fun isLoggedIn(): Boolean = currentToken != null + + /** + * Returns the account label of the current token. + * + * @return The account label or null if not logged in + */ + override fun currentAccount(): String? = currentToken?.accountLabel +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/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..81ac7473 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -0,0 +1,43 @@ +/* + * 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 +import javax.net.ssl.SSLContext + +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, sslContext: SSLContext): URI + + /** Awaits for the browser login result */ + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken + + /** Starts login using the given credentials and returns a valid token */ + suspend fun loginWithCredentials(apiServerUrl: String, username: String, password: String, sslContext: SSLContext): SSOToken + + /** 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..d3ca0ebc --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -0,0 +1,166 @@ +/* + * 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.intellij.openapi.diagnostic.thisLogger +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.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.RedirectUrlBuilder +import com.redhat.devtools.gateway.auth.server.ServerConfigProvider +import kotlinx.coroutines.* +import java.net.URI +import javax.net.ssl.SSLContext + +const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_OPENSHIFT) + } + + override val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private lateinit var authFlow: OpenShiftAuthCodeFlow + + override suspend fun initialize() { + thisLogger().info("OpenShiftAuthSessionManager initialized") + notifyChanged() + } + + override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { + if (apiServerUrl == null) { + thisLogger().error("API Server URL is null") + throw IllegalStateException("Provide API Server URL") + } + + if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login already in progress") + throw IllegalStateException("Login already in progress") + } + + thisLogger().info("Starting OpenShift login for API server: $apiServerUrl") + pendingLogin = CompletableDeferred() + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl = apiServerUrl, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + sslContext = sslContext + ) + + val request = authFlow.startAuthFlow() + thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") + + CoroutineScope(Dispatchers.IO).launch { + try { + thisLogger().debug("Waiting for OAuth callback...") + val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) + if (params == null) { + thisLogger().warn("OAuth callback timed out or was cancelled") + pendingLogin?.completeExceptionally(SsoLoginException.Timeout()) + notifyLoginCancelled() + return@launch + } + + thisLogger().debug("OAuth callback received, handling...") + val token: SSOToken = authFlow.handleCallback(params) + currentToken = token + thisLogger().info("OpenShift login successful for account: ${token.accountLabel}") + pendingLogin?.complete(token) + + } catch (e: Exception) { + thisLogger().error("OpenShift login failed", e) + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "OpenShift login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + thisLogger().error("Failed to start OpenShift login", e) + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + 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 loginWithCredentials( + apiServerUrl: String, + username: String, + password: String, + sslContext: SSLContext + ): SSOToken { + if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login with credentials already in progress") + throw IllegalStateException("Login already in progress") + } + + thisLogger().info("Starting OpenShift credential login for user: $username at $apiServerUrl") + try { + notifyChanged() + + 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 + thisLogger().info("OpenShift credential login successful for account: ${token.accountLabel}") + return token + } catch (e: Exception) { + thisLogger().error("OpenShift credential login failed for user: $username", e) + throw SsoLoginException.Failed(e.message ?: "OpenShift credential login failed") + } finally { + loginInProgress.set(false) + 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 new file mode 100644 index 00000000..59afabf2 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +@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.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.code.RedHatAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.SSOToken +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 javax.net.ssl.SSLContext + +const val LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class RedHatAuthSessionManager : AbstractAuthSessionManager() { + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_REDHAT) + } + + override val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private val authConfig = AuthConfig() + + private val providerMetadata = runBlocking { + OidcProviderMetadataResolver(authConfig.authUrl).resolve() + } + + private lateinit var authFlow: RedHatAuthCodeFlow + + /** + * Called once on plugin startup. + */ + override suspend fun initialize() { + thisLogger().info("RedHatAuthSessionManager initialized") + notifyChanged() + } + + /** + * Starts the login process and returns browser URL. + */ + override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { + if (!loginInProgress.compareAndSet(false, true)) { + thisLogger().warn("Login already in progress") + throw IllegalStateException("Login already in progress") + } + + thisLogger().info("Starting Red Hat SSO login") + pendingLogin = CompletableDeferred() + + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") + + authFlow = RedHatAuthCodeFlow( + clientId = authConfig.clientId, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + providerMetadata = providerMetadata + ) + + val request = authFlow.startAuthFlow() + thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") + + CoroutineScope(Dispatchers.IO).launch { + try { + thisLogger().debug("Waiting for OAuth callback...") + val params = callbackServer.awaitCallback(LOGIN_TIMEOUT_MS) + if (params == null) { + thisLogger().warn("OAuth callback timed out or was cancelled") + pendingLogin?.completeExceptionally( + SsoLoginException.Timeout() + ) + notifyLoginCancelled() + + return@launch + } + + thisLogger().debug("OAuth callback received, handling...") + val token = authFlow.handleCallback(params) + currentToken = token + thisLogger().info("Red Hat SSO login successful for account: ${token.accountLabel}") + + pendingLogin?.complete(token) + } catch (e: Exception) { + thisLogger().error("Red Hat SSO login failed", e) + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "SSO login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + thisLogger().error("Failed to start Red Hat SSO login", e) + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + override suspend fun loginWithCredentials( + apiServerUrl: String, + username: String, + password: String, + sslContext: SSLContext + ): SSOToken { + thisLogger().warn("Credential login not supported for Red Hat SSO") + error("Not supported") + } + + private fun notifyLoginCancelled() { + Notifications.Bus.notify( + Notification( + "RedHat Authentication", + "Login cancelled", + "You closed the browser or the login timed out.", + NotificationType.INFORMATION + ) + ) + } +} 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..a8e5d1aa --- /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() { + class Timeout : SsoLoginException() + data class Failed(val reason: String) : SsoLoginException() +} 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..e9324cd7 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CapturingTrustManager.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.tls + +import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.X509TrustManager + +class CapturingTrustManager( + private val failIfUntrusted: Boolean = false +) : X509TrustManager { + + @Volatile + var serverCertificateChain: Array? = null + private set + + 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) {} + + override fun getAcceptedIssuers(): Array = emptyArray() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt new file mode 100644 index 00000000..8dd53e74 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateFileChooser.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.ui.components.JBTextField +import java.awt.KeyboardFocusManager +import javax.swing.SwingUtilities + +fun browseCertificate(textField: JBTextField, title: String) { + val descriptor = FileChooserDescriptorFactory.singleFile() + .withTitle(title) + .withDescription("Select a certificate or key file") + + val parent = SwingUtilities.getWindowAncestor(textField) + ?: KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + + val chooser = FileChooser.chooseFile( + descriptor, + parent, + null, + null + ) + + chooser?.let { virtualFile -> + textField.text = virtualFile.path + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt new file mode 100644 index 00000000..59c8783a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSource.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.exists +import kotlin.io.path.isReadable + +/** + * Represents a certificate or key source, tracking both the value and its format. + * + * @property value The certificate/key data - either a file path or base64-encoded content + * @property isFilePath True if value is a file path, false if it's base64 data + * @property isModified True if the user has changed this value from what was loaded + */ +data class CertificateSource( + val value: String, + val isFilePath: Boolean, + val isModified: Boolean = false +) { + companion object { + /** + * Creates a CertificateSource from kubeconfig data field (base64). + */ + fun fromData(data: String): CertificateSource { + return CertificateSource( + value = data, + isFilePath = false, + isModified = false + ) + } + + /** + * Creates a CertificateSource from kubeconfig path field. + */ + fun fromPath(path: String): CertificateSource { + return CertificateSource( + value = path, + isFilePath = true, + isModified = false + ) + } + + /** + * Detects input type and creates appropriate CertificateSource. + * Marks as modified since this is user input. + * + * If input is PEM content (not a file path), it will be normalized + * (newlines fixed if needed) and base64-encoded for storage in kubeconfig. + */ + fun fromPathOrPem(input: String): CertificateSource { + val trimmed = input.trim() + val isPath = detectIsPath(trimmed) + + val value = if (isPath) { + expandPath(trimmed) + } else { + // It's PEM or base64 content - normalize and ensure base64-encoded + val normalizedPem = if (PemUtils.isPem(trimmed)) { + PemUtils.ensureProperFormat(trimmed) + } else { + trimmed // Already base64 + } + PemUtils.toBase64(normalizedPem) + } + + return CertificateSource( + value = value, + isFilePath = isPath, + isModified = true + ) + } + + private fun detectIsPath(input: String): Boolean { + // Check for path-like patterns + return when { + input.startsWith("/") -> true + input.startsWith("~/") -> true + input.startsWith("./") -> true + input.startsWith("../") -> true + input.matches(Regex("^[A-Z]:\\\\.*")) -> true // Windows path + PemUtils.isPem(input) -> false + input.length < 50 && input.matches(Regex("^[a-zA-Z0-9._-]+$")) -> true + input.length > 50 && input.matches(Regex("^[A-Za-z0-9+/=\\s]+$")) -> false // Base64 (min length) + else -> true // Default to path for ambiguous cases + } + } + + private fun expandPath(path: String): String { + return when { + path.startsWith("~/") -> { + val home = System.getProperty("user.home") + home + path.substring(1) + } + else -> path + } + } + } + + /** + * Returns the value as a Path if it represents a file path. + */ + fun toPath(): Path = Paths.get(value) + + /** + * Validates the certificate source. + * For file paths, checks existence and readability. + * For base64 data, ensures it's not empty. + */ + fun validate() { + if (isFilePath) { + val path = toPath() + if (!path.exists()) { + throw java.io.FileNotFoundException("Certificate file not found: $value") + } + if (!path.isReadable()) { + throw java.io.IOException("Cannot read certificate file: $value") + } + } + } + + /** + * Creates a copy marked as modified. + */ + fun asModified(): CertificateSource { + return copy(isModified = true) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt new file mode 100644 index 00000000..6b0a632b --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -0,0 +1,140 @@ +/* + * 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 kotlinx.coroutines.* +import java.net.URI +import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException + +class DefaultTlsTrustManager( + private val kubeConfigProvider: suspend () -> List, + private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List) -> Unit, + private val sessionTrustStore: SessionTlsTrustStore, + private val persistentKeyStore: PersistentKeyStore +) : TlsTrustManager { + + override suspend fun ensureTrusted( + serverUrl: String, + decisionHandler: suspend (TlsServerCertificateInfo) -> TlsTrustDecision + ): TlsContext { + + val serverUri = URI(serverUrl) + + val namedCluster = + KubeConfigTlsUtils.findClusterByServer( + serverUrl, + kubeConfigProvider() + ) + + if (namedCluster?.cluster?.insecureSkipTlsVerify == true) { + return SslContextFactory.insecure() + } + + val trustedCerts = mutableListOf() + namedCluster?.let { + trustedCerts += KubeConfigTlsUtils.extractCaCertificates(it) + } + + trustedCerts += sessionTrustStore.get(serverUrl) + + val keyStore = persistentKeyStore.loadOrCreate() + val persistentAlias = "host:${serverUri.host}" + + val persistentCert = keyStore.getCertificate(persistentAlias) + if (persistentCert is X509Certificate) { + trustedCerts += persistentCert + } + + if (trustedCerts.isNotEmpty()) { + try { + val tlsContext = SslContextFactory.fromTrustedCerts(trustedCerts) + withContext(Dispatchers.IO) { + TlsProbe.connect(serverUri, tlsContext.sslContext) + } + return tlsContext + } catch (e: SSLHandshakeException) { + // Certificate changed or invalid → continue to capture + } + } + + val captureContext = SslContextFactory.captureOnly() + + try { + withContext(Dispatchers.IO) { + 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 + ) + + val decision = decisionHandler(info) + if (!decision.trusted) { + throw TlsTrustRejectedException() + } + + when (decision.scope) { + TlsTrustScope.SESSION_ONLY -> { + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + } + + TlsTrustScope.PERMANENT -> { + sessionTrustStore.put(serverUrl, listOf(trustAnchor)) + + if (namedCluster != null) { + kubeConfigWriter(namedCluster, listOf(trustAnchor)) + } + KeyStoreUtils.addCertificate( + keyStore, + persistentAlias, + trustAnchor + ) + persistentKeyStore.save(keyStore) + } + + null -> error("Trusted decision without scope") + } + + 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..8d31a407 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/KubeConfigTlsUtils.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.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 + +import kotlin.io.path.readText + +object KubeConfigTlsUtils { + + fun findClusterByServer( + serverUrl: String, + kubeConfigs: List + ): KubeConfigNamedCluster? = + kubeConfigs + .flatMap { it.clusters ?: emptyList() } + .mapNotNull { KubeConfigNamedCluster.fromMap(it as Map<*, *>) } + .firstOrNull { it.cluster.server == serverUrl } + + fun extractCaCertificates( + namedCluster: KubeConfigNamedCluster + ): List { + val caSource = namedCluster.cluster.certificateAuthority ?: return emptyList() + val caContent = if (caSource.isFilePath) { + caSource.toPath().readText() + } else { + Base64.getDecoder().decode(caSource.value).toString(Charsets.UTF_8) + } + + val factory = CertificateFactory.getInstance("X.509") + + return factory + .generateCertificates(caContent.byteInputStream()) + .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..bf203e19 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtils.kt @@ -0,0 +1,170 @@ +/* + * 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 { + + /** + * Detects whether content is PEM-encoded (as opposed to base64). + * + * @param content The content to check whether it's PEM-encoded. + * @returns `true` if the given string is PEM-encoded. Returns `false` otherwise. + */ + fun isPem(content: String): Boolean = content.contains("-----BEGIN") + + /** + * Ensures PEM has proper formatting with newlines. + * Reformats single-line or malformed PEM (e.g., from JBTextField paste). + */ + fun ensureProperFormat(pem: String): String { + val newlineCount = pem.count { it == '\n' } + return if (newlineCount < 2) { + reformatSingleLinePem(pem) + } else { + pem + } + } + + fun toPem(certificate: X509Certificate): String { + val base64 = Base64.getMimeEncoder(64, "\n".toByteArray()) + .encodeToString(certificate.encoded) + + return buildString { + append("-----BEGIN CERTIFICATE-----\n") + append(base64) + append("\n-----END CERTIFICATE-----\n") + } + } + + /** + * Base64-encodes PEM content; passes through already-base64 content unchanged. + */ + fun toBase64(value: String): String { + return if (isPem(value)) { + Base64.getEncoder().encodeToString(value.toByteArray()) + } else { + value + } + } + + fun parsePrivateKey(pemOrBase64: String): PrivateKey { + val normalized = normalizePem(pemOrBase64) + + // JBTextField can strip or mangle newlines from pasted PEM → reformat if needed + val reformatted = if (isPem(normalized)) { + // Count newlines - properly formatted PEM should have multiple lines + val newlineCount = normalized.count { it == '\n' } + // Reformat if single-line or has very few newlines (malformed) + if (newlineCount < 2) { + reformatSingleLinePem(normalized) + } else { + normalized + } + } else { + normalized + } + + val cleaned = reformatted + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val decoded = Base64.getDecoder().decode(cleaned) + + 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 (!isPem(trimmed)) { + // It's base64 from kubeconfig → decode to PEM + String(Base64.getDecoder().decode(trimmed)) + } else { + trimmed + } + } + + fun parseCertificate(pemOrBase64: String): X509Certificate { + val normalized = normalizePem(pemOrBase64) + + // JBTextField can strip or mangle newlines from pasted PEM → reformat if needed + val cleaned = if (isPem(normalized)) { + // Count newlines - properly formatted PEM should have multiple lines + val newlineCount = normalized.count { it == '\n' } + // Reformat if single-line or has very few newlines (malformed) + if (newlineCount < 2) { + reformatSingleLinePem(normalized) + } else { + normalized + } + } else { + normalized + } + + val factory = CertificateFactory.getInstance("X.509") + return factory.generateCertificate(cleaned.byteInputStream()) as X509Certificate + } + + /** + * Reformats PEM that has newlines stripped or malformed (from text field paste). + * Extracts base64 content and rebuilds proper PEM format with line breaks. + * Handles all PEM types (CERTIFICATE, PRIVATE KEY, RSA PRIVATE KEY, EC PRIVATE KEY, etc.). + */ + private fun reformatSingleLinePem(singleLinePem: String): String { + // Match any PEM header/footer (CERTIFICATE, PRIVATE KEY, PUBLIC KEY, etc.) + val beginMarker = Regex("-----BEGIN [A-Z ]+-----") + val endMarker = Regex("-----END [A-Z ]+-----") + + val beginMatch = beginMarker.find(singleLinePem) ?: return singleLinePem + val endMatch = endMarker.find(singleLinePem) ?: return singleLinePem + + val header = beginMatch.value + val footer = endMatch.value + val base64Content = singleLinePem.substring( + beginMatch.range.last + 1, + endMatch.range.first + ).replace("\\s+".toRegex(), "") // Remove all whitespace (spaces, newlines, tabs) + + if (base64Content.isEmpty()) { + throw IllegalArgumentException("No certificate data found between PEM markers") + } + + // Rebuild with proper line breaks (64 chars per line, standard PEM format) + val formattedBase64 = base64Content.chunked(64).joinToString("\n") + + return "$header\n$formattedBase64\n$footer\n" + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/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..a72c2c6a --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/SslContextFactory.kt @@ -0,0 +1,83 @@ +/* + * 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 + +private const val SSL_PROTOCOL = "TLS" + +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(SSL_PROTOCOL).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(SSL_PROTOCOL).apply { + init(null, tmf.trustManagers, SecureRandom()) + } + + return TlsContext(sslContext, trustManager) + } + + fun captureOnly(failIfUntrusted: Boolean = true): TlsContext { + val capturingTrustManager = CapturingTrustManager(failIfUntrusted) + + val sslContext = SSLContext.getInstance(SSL_PROTOCOL).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..25515ef8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.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.net.URI +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket + +object TlsProbe { + + private const val DEFAULT_HTTPS_PORT = 443 + + fun connect(serverUri: URI, sslContext: SSLContext) { + val socketFactory = sslContext.socketFactory + val port = if (serverUri.port != -1) serverUri.port else DEFAULT_HTTPS_PORT + + (socketFactory.createSocket(serverUri.host, port) as SSLSocket).use { socket -> + socket.startHandshake() + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/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..c59a0e6f --- /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..641c3fe8 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigEntries.kt @@ -11,6 +11,8 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.sanitizeName import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.urlToName import io.kubernetes.client.util.KubeConfig @@ -52,15 +54,24 @@ data class KubeConfigNamedCluster( data class KubeConfigCluster( val server: String, - val certificateAuthorityData: String? = null, + val certificateAuthority: CertificateSource? = null, val insecureSkipTlsVerify: Boolean? = null ) { companion object { fun fromMap(map: Map<*, *>): KubeConfigCluster? { val server = map["server"] as? String ?: return null + + val caSource = when { + map["certificate-authority-data"] is String -> + CertificateSource.fromData(map["certificate-authority-data"] as String) + map["certificate-authority"] is String -> + CertificateSource.fromPath(map["certificate-authority"] as String) + else -> null + } + return KubeConfigCluster( server = server, - certificateAuthorityData = map["certificate-authority-data"] as? String, + certificateAuthority = caSource, insecureSkipTlsVerify = map["insecure-skip-tls-verify"] as? Boolean ) } @@ -69,7 +80,15 @@ data class KubeConfigCluster( fun toMap(): MutableMap { val map = mutableMapOf() map["server"] = server - certificateAuthorityData?.let { map["certificate-authority-data"] = it } + + certificateAuthority?.let { ca -> + if (ca.isFilePath) { + map["certificate-authority"] = ca.value + } else { + map["certificate-authority-data"] = PemUtils.toBase64(ca.value) + } + } + insecureSkipTlsVerify?.let { map["insecure-skip-tls-verify"] = it } return map } @@ -201,6 +220,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?.clientCertificate, user?.clientKey) + } + fun isTokenAuth(kubeConfig: KubeConfig): Boolean { return kubeConfig.credentials?.containsKey(KubeConfig.CRED_TOKEN_KEY) == true } @@ -216,17 +246,33 @@ data class KubeConfigNamedUser( data class KubeConfigUser( var token: String? = null, - val clientCertificateData: String? = null, - val clientKeyData: String? = null, + val clientCertificate: CertificateSource? = null, + val clientKey: CertificateSource? = null, val username: String? = null, val password: String? = null ) { companion object { fun fromMap(map: Map<*, *>): KubeConfigUser { + val certSource = when { + map["client-certificate-data"] is String -> + CertificateSource.fromData(map["client-certificate-data"] as String) + map["client-certificate"] is String -> + CertificateSource.fromPath(map["client-certificate"] as String) + else -> null + } + + val keySource = when { + map["client-key-data"] is String -> + CertificateSource.fromData(map["client-key-data"] as String) + map["client-key"] is String -> + CertificateSource.fromPath(map["client-key"] as String) + else -> null + } + return KubeConfigUser( token = map["token"] as? String, - clientCertificateData = map["client-certificate-data"] as? String, - clientKeyData = map["client-key-data"] as? String, + clientCertificate = certSource, + clientKey = keySource, username = map["username"] as? String, password = map["password"] as? String ) @@ -236,8 +282,23 @@ data class KubeConfigUser( fun toMap(): MutableMap { val map = mutableMapOf() token?.let { map["token"] = it } - clientCertificateData?.let { map["client-certificate-data"] = it } - clientKeyData?.let { map["client-key-data"] = it } + + clientCertificate?.let { cert -> + if (cert.isFilePath) { + map["client-certificate"] = cert.value + } else { + map["client-certificate-data"] = PemUtils.toBase64(cert.value) + } + } + + clientKey?.let { key -> + if (key.isFilePath) { + map["client-key"] = key.value + } else { + map["client-key-data"] = PemUtils.toBase64(key.value) + } + } + username?.let { map["username"] = it } password?.let { map["password"] = it } return map @@ -245,4 +306,3 @@ data class KubeConfigUser( } - diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt index 85247811..b492f5d7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUpdate.kt @@ -13,6 +13,7 @@ package com.redhat.devtools.gateway.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.text.UniqueNameGenerator +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.path import com.redhat.devtools.gateway.openshift.Utils import io.kubernetes.client.util.KubeConfig @@ -35,6 +36,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 +207,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, + clientCertificate = CertificateSource.fromData(clientCertPem), + clientKey = CertificateSource.fromData(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..36de5cdf 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUtils.kt @@ -1,7 +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.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.EnvironmentUtil +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.openshift.Cluster import io.kubernetes.client.util.KubeConfig import java.io.File @@ -33,7 +45,10 @@ object KubeConfigUtils { kubeConfig.clusters?.mapNotNull { cluster -> val namedCluster = KubeConfigNamedCluster.fromMap(cluster as Map<*, *>) ?: return@mapNotNull null val token = KubeConfigNamedUser.getUserTokenForCluster(namedCluster.name, kubeConfig) - val cluster = toCluster(namedCluster, token) + val clientCert = KubeConfigNamedUser.getUserClientCertForCluster(namedCluster.name, kubeConfig) + val clientCertSource = clientCert?.first + val clientKeySource = clientCert?.second + val cluster = toCluster(namedCluster, token, clientCertSource, clientKeySource) logger.debug("Parsed cluster: ${cluster.name} at ${cluster.url}") cluster } ?: emptyList() @@ -70,11 +85,19 @@ object KubeConfigUtils { } } - private fun toCluster(clusterEntry: KubeConfigNamedCluster, userToken: String?): Cluster { + private fun toCluster( + clusterEntry: KubeConfigNamedCluster, + userToken: String?, + clientCertSource: CertificateSource?, + clientKeySource: CertificateSource? + ): Cluster { return Cluster( url = clusterEntry.cluster.server, name = clusterEntry.name, - token = userToken + certificateAuthority = clusterEntry.cluster.certificateAuthority, + token = userToken, + clientCert = clientCertSource, + clientKey = clientKeySource ) } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt index ad188413..d6707687 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/ApiExceptionUtils.kt @@ -19,4 +19,23 @@ fun ApiException.isNotFound(): Boolean { fun ApiException.isUnauthorized(): Boolean { return code == 401 +} + +/** + * Converts HTTP status code to human-readable message. + */ +fun ApiException.codeToReasonPhrase(): String { + val statusMessage = when (code) { + 400 -> "Bad Request" + 401 -> "Unauthorized" + 403 -> "Forbidden" + 404 -> "Not Found" + 408 -> "Request Timeout" + 500 -> "Internal Server Error" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + 504 -> "Gateway Timeout" + else -> "HTTP Error $code" + } + return statusMessage } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt index 013bb6bd..1ee05eec 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -11,14 +11,27 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toName import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toUriWithHost data class Cluster( val name: String, val url: String, - val token: String? = null + val certificateAuthority: CertificateSource? = null, + val token: String? = null, + val clientCert: CertificateSource? = null, + val clientKey: CertificateSource? = null ) { + init { + require(!(token != null && clientCert != null)) { + "Cluster cannot have both token and client certificate authentication" + } + + require((clientCert == null) == (clientKey == 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..8561e361 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,19 +12,58 @@ package com.redhat.devtools.gateway.openshift import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.PemUtils +import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.util.ClientBuilder import io.kubernetes.client.util.Config import io.kubernetes.client.util.KubeConfig +import kotlin.io.path.readText +import java.security.KeyStore +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager class OpenShiftClientFactory(private val configUtils: KubeConfigUtils) { private val userName = "openshift_user" private val contextName = "openshift_context" private val clusterName = "openshift_cluster" - + private var lastUsedKubeConfig: KubeConfig? = null + class Builder internal constructor( + private val factory: OpenShiftClientFactory, + private val server: String, + private val token: String + ) { + private var readTimeoutSeconds: Long = 0 + + fun readTimeout(timeout: Long, unit: TimeUnit): Builder { + this.readTimeoutSeconds = unit.toSeconds(timeout) + return this + } + + fun build(): ApiClient { + val client = factory.create(server, token) + if (readTimeoutSeconds > 0) { + client.httpClient = client.httpClient.newBuilder() + .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) + .build() + } + return client + } + } + + fun builder(server: String, token: String): Builder { + return Builder(this, server, token) + } + fun create(): ApiClient { val paths = configUtils.getAllConfigFiles() if (paths.isEmpty()) { @@ -34,52 +73,213 @@ 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, token: String): ApiClient { + val kubeConfig = createKubeConfig(server, null, token.toCharArray(), null, null) lastUsedKubeConfig = kubeConfig return Config.fromConfig(kubeConfig) } - - fun isTokenAuth(): Boolean { - return lastUsedKubeConfig?.let { - KubeConfigUtils.isCurrentUserTokenAuth(it) - } ?: false + + fun create( + server: String, + certificateAuthority: CertificateSource? = null, + token: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null, + tlsContext: TlsContext + ): ApiClient { + + val usingToken = token?.isNotEmpty() == true + val usingClientCert = clientCert != null && clientKey != null + + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + + val kubeConfig = createKubeConfig(server, null, token, clientCert, clientKey) + lastUsedKubeConfig = kubeConfig + + val client = Config.fromConfig(kubeConfig) + + val trustManager: X509TrustManager = + if (certificateAuthority != null) { + createTrustManager(certificateAuthority) + } else { + tlsContext.trustManager + } + + val sslContext = createSSLContext(trustManager, usingClientCert, clientCert, clientKey) + + client.httpClient = client.httpClient.newBuilder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + + return client } - private fun createKubeConfig(server: String, token: CharArray): KubeConfig { - val cluster = mapOf( - "name" to clusterName, - "cluster" to mapOf( - "server" to server.trim(), - "insecure-skip-tls-verify" to true + /** + * Resolves CertificateSource to actual content. + * If it's a file path, reads the file. Otherwise returns the value. + */ + private fun resolveCertificateSource(source: CertificateSource): String { + return if (source.isFilePath) { + try { + source.toPath().readText() + } catch (e: Exception) { + throw java.io.IOException("Failed to read certificate file: ${source.value}", e) + } + } else { + source.value + } + } + + private fun createSSLContext( + trustManager: X509TrustManager, + usingClientCert: Boolean, + clientCert: CertificateSource?, + clientKey: CertificateSource? + ): SSLContext { + val keyManagers: Array? = + if (usingClientCert && clientCert != null && clientKey != null) { + createKeyManagers(clientCert, clientKey) + } else { + null + } + + return SSLContext.getInstance("TLS").apply { + init( + keyManagers, + arrayOf(trustManager), + SecureRandom() ) + } + } + + private fun createTrustManager( + caSource: CertificateSource + ): X509TrustManager { + + val caContent = resolveCertificateSource(caSource) + val caCert = PemUtils.parseCertificate(caContent) + + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + keyStore.setCertificateEntry("ca", caCert) + + val tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() ) + tmf.init(keyStore) + + return tmf.trustManagers + .filterIsInstance() + .first() + } + + private fun createKeyManagers( + certSource: CertificateSource, + keySource: CertificateSource + ): Array { + + val certContent = resolveCertificateSource(certSource) + val keyContent = resolveCertificateSource(keySource) + + val certificate = PemUtils.parseCertificate(certContent) + val privateKey = PemUtils.parsePrivateKey(keyContent) + + val keyStore = KeyStore.getInstance("PKCS12") + keyStore.load(null) + + keyStore.setKeyEntry( + "client", + privateKey, + CharArray(0), + arrayOf(certificate) + ) + + val kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ) + kmf.init(keyStore, CharArray(0)) + + return kmf.keyManagers + } + + private fun createKubeConfig( + server: String, + certificateAuthority: CertificateSource? = null, + token: CharArray? = null, + clientCert: CertificateSource? = null, + clientKey: CertificateSource? = null + ): KubeConfig { + + val usingToken = token != null + val usingClientCert = clientCert != null && clientKey != null - val user = mapOf( + require(usingToken.xor(usingClientCert)) { + "Provide either token OR clientCert + clientKey." + } + + val cluster = mutableMapOf( + "server" to server.trim() + ) + + certificateAuthority?.let { ca -> + if (ca.isFilePath) { + cluster["certificate-authority"] = ca.value.trim() + } else { + cluster["certificate-authority-data"] = PemUtils.toBase64(ca.value.trim()) + } + } + + val clusterEntry = mapOf( + "name" to clusterName, + "cluster" to cluster + ) + + val userAuth = mutableMapOf() + + if (usingToken) { + userAuth["token"] = String(token).trim() + } else { + clientCert?.let { cert -> + if (cert.isFilePath) { + userAuth["client-certificate"] = cert.value.trim() + } else { + userAuth["client-certificate-data"] = PemUtils.toBase64(cert.value.trim()) + } + } + clientKey?.let { key -> + if (key.isFilePath) { + userAuth["client-key"] = key.value.trim() + } else { + userAuth["client-key-data"] = PemUtils.toBase64(key.value.trim()) + } + } + } + + val userEntry = mapOf( "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,9 +287,9 @@ 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 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt index c84c89bd..acbd4d58 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<*> { @@ -28,11 +34,27 @@ class Projects(private val client: ApiClient) { return response["items"] as List<*> } + /** + * Checks if the current authenticated identity can access namespaces (works for Kubernetes and OpenShift). + * Returns `true` if the client is authorized. Returns `false` otherwise. + */ @Throws(ApiException::class) fun isAuthenticated(): Boolean { - list() - // throws if not authenticated - return true - } + 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/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/util/ClipboardReader.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.kt new file mode 100644 index 00000000..6953ad97 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardReader.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor + +/** + * Interface for reading text content from the system clipboard. + * This abstraction allows for testing without requiring a display server. + */ +interface ClipboardReader { + /** + * Reads the current text content from the clipboard. + * + * @return The clipboard text or null if empty or not text + */ + fun readText(): String? +} + +/** + * Default implementation that reads from the system clipboard. + */ +class SystemClipboardReader : ClipboardReader { + override fun readText(): String? { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val contents = clipboard.getContents(null) ?: return null + + return try { + if (contents.isDataFlavorSupported(DataFlavor.stringFlavor)) { + contents.getTransferData(DataFlavor.stringFlavor) as? String + } else { + null + } + } catch (_: Exception) { + null + }?.trim() + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt new file mode 100644 index 00000000..b6ba5a14 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitor.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import kotlinx.coroutines.* +import kotlin.time.Duration.Companion.milliseconds + +/** + * Monitors the system clipboard for OpenShift tokens and notifies listeners when detected. + * + * This class polls the clipboard at regular intervals and detects when a valid OpenShift token + * (format: sha256~[base64-like-chars]) appears. When a new token is detected, registered listeners + * are notified. + * + * ## Usage + * ```kotlin + * val monitor = ClipboardTokenMonitor() + * monitor.addListener { token -> + * println("Token detected: $token") + * } + * monitor.start() + * // ... later ... + * monitor.stop() + * ``` + * + * @param pollingIntervalMs Interval between clipboard checks in milliseconds. Defaults to 500ms. + * @param clipboardReader Reader for accessing clipboard content. Defaults to system clipboard. + */ +class ClipboardTokenMonitor( + private val pollingIntervalMs: Long = 500, + private val clipboardReader: ClipboardReader = SystemClipboardReader() +) { + + companion object { + private val OPENSHIFT_TOKEN_REGEX = Regex("^sha256~[A-Za-z0-9_-]{20,}$") + + /** + * Checks if a string is a valid OpenShift token. + * Format: sha256~[base64-like-characters with at least 20 chars] + * + * @param token The string to check + * @return true if the token matches OpenShift token format, false otherwise + */ + fun isOpenShiftToken(token: String?): Boolean { + if (token == null) return false + return OPENSHIFT_TOKEN_REGEX.matches(token.trim()) + } + } + + private val listeners = mutableListOf() + private var lastClipboardValue: String? = null + private var pollingJob: Job? = null + + /** + * Listener interface for token detection events. + */ + fun interface TokenDetectedListener { + /** + * Called when a new OpenShift token is detected in the clipboard. + * + * @param token The detected token string + */ + fun onTokenDetected(token: String) + } + + /** + * Adds a listener to be notified when tokens are detected. + * + * @param listener The listener to add + */ + fun addListener(listener: TokenDetectedListener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + /** + * Removes a previously registered listener. + * + * @param listener The listener to remove + */ + fun removeListener(listener: TokenDetectedListener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + /** + * Starts monitoring the clipboard for tokens. + * Polling runs on a background coroutine. + */ + fun start() { + if (pollingJob?.isActive == true) { + return + } + + this.pollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + val value = readClipboardText() + + if (value != null && value != lastClipboardValue) { + lastClipboardValue = value + + if (isOpenShiftToken(value)) { + notifyListeners(value) + } + } + + delay(pollingIntervalMs.milliseconds) + } + } + } + + /** + * Stops monitoring the clipboard. + */ + fun stop() { + pollingJob?.cancel() + pollingJob = null + } + + /** + * Checks the clipboard immediately for a token without starting continuous polling. + * + * @return The token if one is found, null otherwise + */ + fun checkNow(): String? { + val token = readClipboardText() + return if (isOpenShiftToken(token)) token else null + } + + /** + * Reads the current text content from the clipboard. + * + * @return The clipboard text or null if empty or not text + */ + private fun readClipboardText(): String? { + return clipboardReader.readText() + } + + /** + * Notifies all registered listeners of a detected token. + */ + private fun notifyListeners(token: String) { + val listenersCopy = synchronized(listeners) { listeners.toList() } + listenersCopy.forEach { listener -> + try { + listener.onTokenDetected(token) + } catch (_: Exception) { + // Ignore listener exceptions + } + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index 5d5e52e6..68095d08 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -11,46 +11,15 @@ */ package com.redhat.devtools.gateway.util -import com.google.gson.Gson -import io.kubernetes.client.openapi.ApiException import kotlinx.coroutines.TimeoutCancellationException import java.util.concurrent.CancellationException import java.util.concurrent.TimeoutException - -fun Throwable.rootMessage(): String { - var cause: Throwable? = this - while (cause?.cause != null) { - cause = cause.cause - } - return cause?.message?.trim() - ?: messageWithoutPrefix() - ?: "Unknown error" -} - fun Throwable.messageWithoutPrefix(): String? { return message?.trim() ?: message?.substringAfter(":")?.trim() } -fun Throwable.message(): String { - return if (this is ApiException) { - message() - } else { - message.orEmpty() - } -} - -fun ApiException.message(): String { - val response = Gson().fromJson(responseBody, Map::class.java) - val msg = try { - response["message"]?.toString() - } catch (e: Exception) { - e.rootMessage() - } - return "Reason: $msg" -} - fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationException || this is TimeoutException ) fun Throwable.isCancellationException(): Boolean = (this is CancellationException && !isTimeoutException() ) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/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 2989a358..495daa6d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -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/ @@ -11,34 +11,45 @@ */ package com.redhat.devtools.gateway.view.steps -import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.application.ApplicationManager +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.redhat.devtools.gateway.auth.tls.browseCertificate import com.intellij.ui.components.JBCheckBox +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.panel +import com.intellij.ui.dsl.builder.* 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.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.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.util.isCancellationException +import com.redhat.devtools.gateway.view.steps.auth.* +import com.redhat.devtools.gateway.view.ui.Dialogs +import com.redhat.devtools.gateway.view.ui.FilteringComboBox +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.view.ui.requestInitialFocus import 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 @@ -56,14 +67,29 @@ 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 saveKubeconfigCheckbox = JBCheckBox( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration") + ).apply { + isOpaque = false + background = null + isSelected = saveToKubeconfig + addActionListener { + saveToKubeconfig = isSelected + } + } + + private val sessionManager = + ApplicationManager.getApplication() + .getService(RedHatAuthSessionManager::class.java) - private var tfToken = JBTextField() + private var tfCertAuthority = JBTextField() .apply { - document.addDocumentListener(onTokenChanged()) + document.addDocumentListener(onFieldChanged()) PasteClipboardMenu.addTo(this) - addKeyListener(createEnterKeyListener()) } + private var tfServer = FilteringComboBox.create( { it?.toString() ?: "" }, @@ -71,29 +97,138 @@ class DevSpacesServerStepView( ) .apply { addItemListener(::onClusterSelected) - PasteClipboardMenu.addTo(this.editor.editorComponent as JTextField) - (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) + val editor = this.editor.editorComponent as JTextField + PasteClipboardMenu.addTo(editor) + editor.addKeyListener(createEnterKeyListener()) } - override val component = panel { - row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { - font = JBFont.h2().asBold() + private val authStrategies: List by lazy { + val tokenStrategy = TokenAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::onFieldChanged, + ::createEnterKeyListener + ) + + val setTokenDisplay: suspend (String) -> Unit = { token -> + withContext(Dispatchers.Main) { + tokenStrategy.tfToken.text = token } } + + listOf( + tokenStrategy, + OpenShiftOAuthAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + setTokenDisplay + ), + ClientCertificateAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::saveKubeconfigWithCert, + ::onFieldChanged + ), + OpenShiftCredentialsAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + ::onFieldChanged, + ::createEnterKeyListener, + setTokenDisplay + ), + RedHatSSOAuthenticationStrategy( + tfServer, + ::saveKubeconfig, + sessionManager + ) + ) + } + + private var currentStrategy: AuthenticationStrategy? = null + get() = field ?: authStrategies.firstOrNull().also { field = it } + + private inline fun findStrategy(): T? = + authStrategies.firstOrNull { it is T } as? T + + private fun getCurrentAuthTokenValue(): CharArray? = + when (currentStrategy?.getAuthMethod()) { + AuthMethod.TOKEN -> (currentStrategy as? TokenAuthenticationStrategy)?.tfToken?.password + else -> null // other tabs don't have a token yet + } + + private fun tabPanel(p: JComponent): JComponent = + JBUI.Panels.simplePanel(p).apply { + isOpaque = true + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + border = JBUI.Borders.emptyTop(16) + } + + private val authTabs = JBTabbedPane().apply { + isOpaque = true + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + + // Add tabs for each strategy + authStrategies.forEach { strategy -> + val panel = strategy.createPanel() + // Make inner panel transparent so wrapper background shows through + panel.isOpaque = false + addTab(strategy.getTabTitle(), tabPanel(panel)) + } + + addChangeListener { + currentStrategy = authStrategies.getOrNull(selectedIndex) + + saveKubeconfigCheckbox.isVisible = + currentStrategy?.getAuthMethod() != AuthMethod.REDHAT_SSO + + enableNextButton?.invoke() + } + } + + 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.token")) { - cell(tfToken).align(Align.FILL) + collapsibleGroup( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.advanced_group") + ) { + row( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.certificate_authority") + ) { + cell(tfCertAuthority) + .align(Align.FILL) + .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfCertAuthority, "Select Certificate Authority File") + } + } + } + group( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication") + ) { + row { + cell(authTabs) + .align(Align.FILL) + } + } + row { + cell(saveKubeconfigCheckbox) } - row("") { - cell(updateKubeconfigCheckbox).applyToComponent { - isOpaque = false - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + }.apply { + 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) @@ -106,45 +241,73 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() + enableNextButton?.invoke() + + findStrategy()?.startMonitoring(component) + } + + override fun onDispose() { + findStrategy()?.stopMonitoring() + + stopKubeconfigMonitor() + super.onDispose() } private fun onClusterSelected(event: ItemEvent) { if (event.stateChange == ItemEvent.SELECTED) { (event.item as? Cluster)?.let { selectedCluster -> if (allClusters.contains(selectedCluster)) { - tfToken.text = selectedCluster.token - updateKubeconfigCheckbox.isSelected = false + tfCertAuthority.text = selectedCluster.certificateAuthority?.value ?: "" + findStrategy()?.tfToken?.apply { + text = selectedCluster.token + } + findStrategy()?.apply { + tfClientCert.text = selectedCluster.clientCert?.value ?: "" + tfClientKey.text = selectedCluster.clientKey?.value ?: "" + } + saveKubeconfigCheckbox.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 = + 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) + + // Only TokenAuthenticationStrategy requires token diff to enable save + val requiresTokenDiff = currentStrategy is TokenAuthenticationStrategy + + saveKubeconfigCheckbox.isEnabled = !allClusters.contains(cluster) - || (cluster?.token ?: "") != token + || !requiresTokenDiff + || tokenChanged } + private fun createEnterKeyListener(): KeyAdapter { return object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { @@ -158,83 +321,194 @@ 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() + val kubeConfigCurrentCluster = withContext(Dispatchers.IO) { + KubeConfigUtils.getCurrentClusterName() } + ApplicationManager.getApplication().invokeLater( + { + 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 } 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()) + val serverDisplay = server.removePrefix("https://").removePrefix("http://") + val strategy = currentStrategy ?: return false var success = false - stopKubeconfigMonitor() - - ProgressManager.getInstance().runProcessWithProgressSynchronously( - { - try { - val indicator = ProgressManager.getInstance().progressIndicator - saveKubeconfig(tfServer.selectedItem as? Cluster?, tfToken.text, indicator) - indicator.text = "Checking connection..." - Projects(client).isAuthenticated() - success = true - } catch (e: Exception) { - Dialogs.error(e.message(), "Connection failed") - throw e - } - }, - "Checking Connection...", - true, - null - ) + if (!confirmAuthSwitchIfNeeded()) return false + + onDispose() + + try { + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + runBlocking(Dispatchers.IO) { + val indicator = ProgressManager.getInstance().progressIndicator + indicator.text = "Connecting to cluster..." + + val tlsContext = resolveSslContext(server) + val certAuthorityData = tfCertAuthority.text + + strategy.authenticate( + selectedCluster, + server, + certAuthorityData, + tlsContext, + indicator, + devSpacesContext + ) + } + }, + "Connecting to OpenShift...", + true, + null + ) + success = true + } catch (e: AuthenticationException) { + if (!e.isCancellationException()) { + thisLogger().warn(e) + Dialogs.error( + "Could not connect to cluster $serverDisplay.\n\nReason: ${e.message ?: "Unknown error"}", + "Connection Failed" + ) + } + } catch (e: Exception) { + if (!e.isCancellationException()) { + thisLogger().warn(e) + Dialogs.error( + "Could not connect to cluster $serverDisplay: ${e.message ?: "Unknown error"}", + "Connection Failed" + ) + } + } if (success) { - settings.save(tfServer.selectedItem as? Cluster) - devSpacesContext.client = client + settings.save(selectedCluster) } return success } - override fun isNextEnabled(): Boolean { - return tfServer.selectedItem != null - && tfToken.text.isNotEmpty() - } + private fun confirmAuthSwitchIfNeeded(): Boolean { + val tokenPresent = findStrategy()?.tfToken?.password?.isNotEmpty() == true + val certStrategy = findStrategy() + val certPresent = certStrategy?.tfClientCert?.text?.isNotBlank() == true + || certStrategy?.tfClientKey?.text?.isNotBlank() == true + + val (message, shouldAsk) = when (currentStrategy?.getAuthMethod()) { + AuthMethod.TOKEN -> { + if (certPresent) { + "Switching to token authentication will remove the configured client certificate. Continue?" to true + } else null to false + } - private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { - if (cluster == null - || token.isNullOrBlank() - || !updateKubeconfigCheckbox.isSelected) { - return + AuthMethod.CLIENT_CERTIFICATE -> { + if (tokenPresent) { + "Switching to client certificate authentication will remove the configured token. Continue?" to true + } else null to false } - 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") + else -> null to false + } + + if (!shouldAsk || message == null) return true + + return MessageDialogBuilder + .yesNo( + "Change Authentication Method", + message + ) + .yesText("Switch") + .noText("Cancel") + .ask(component) + } + + override fun isNextEnabled(): Boolean = + currentStrategy?.isNextEnabled() ?: false + + private val sessionTrustStore = SessionTlsTrustStore() + private val persistentKeyStore = PersistentKeyStore( + path = Paths.get( + PathManager.getConfigPath(), + "devspaces", + "tls-truststore.p12" + ), + password = CharArray(0) + ) + + private val tlsTrustManager = DefaultTlsTrustManager( + kubeConfigProvider = { + withContext(Dispatchers.IO) { + KubeConfigUtils.getAllConfigs( + KubeConfigUtils.getAllConfigFiles() + ) } + }, + kubeConfigWriter = { namedCluster, certs -> + withContext(Dispatchers.IO) { + 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 (!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") + } + } + + private fun saveKubeconfigWithCert(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) { @@ -251,7 +525,14 @@ class DevSpacesServerStepView( ?: clusters.firstOrNull { it.id == saved?.id } ?: clusters.firstOrNull() tfServer.selectedItem = toSelect - tfToken.text = toSelect?.token ?: "" + tfCertAuthority.text = toSelect?.certificateAuthority?.value ?: "" + findStrategy()?.tfToken?.apply { + text = toSelect?.token ?: "" + } + findStrategy()?.apply { + tfClientCert.text = toSelect?.clientCert?.value ?: "" + tfClientKey.text = toSelect?.clientKey?.value ?: "" + } } 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/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt new file mode 100644 index 00000000..278ff801 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.openapi.progress.ProgressIndicator +import com.redhat.devtools.gateway.auth.tls.CertificateSource +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils +import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory +import com.redhat.devtools.gateway.openshift.Projects +import com.redhat.devtools.gateway.openshift.codeToReasonPhrase +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.ApiException + +/** + * Abstract base class for authentication strategies. + * Provides common functionality and access to shared UI components. + */ +abstract class AbstractAuthenticationStrategy( + protected val tfServer: Any, // FilteringComboBox + protected val saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit +) : AuthenticationStrategy { + + /** + * Checks if a server/cluster has been selected. + */ + protected fun isServerSelected(): Boolean { + return (tfServer as? javax.swing.JComboBox<*>)?.selectedItem != null + } + + /** + * Creates a validated API client. + */ + @Throws(AuthenticationException::class) + protected fun createValidatedApiClient( + server: String, + certificateAuthorityData: String? = null, + token: String? = null, + clientCertPem: String? = null, + clientKeyPem: String? = null, + tlsContext: TlsContext, + errorMessage: String? = null + ): ApiClient = try { + val caSource = certificateAuthorityData?.let { CertificateSource.fromPathOrPem(it) } + val certSource = clientCertPem?.let { CertificateSource.fromPathOrPem(it) } + val keySource = clientKeyPem?.let { CertificateSource.fromPathOrPem(it) } + + caSource?.validate() + certSource?.validate() + keySource?.validate() + + OpenShiftClientFactory(KubeConfigUtils) + .create( + server, + caSource, + token?.toCharArray(), + certSource, + keySource, + tlsContext + ) + .also { client -> + require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + } + } catch (e: ApiException) { + throw AuthenticationException(e.codeToReasonPhrase(), e) + } catch (e: IllegalArgumentException) { + throw AuthenticationException(e.message ?: "Authentication failed", e) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt new file mode 100644 index 00000000..5a8a365e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthMethod.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +/** + * Enumeration of supported authentication methods. + */ +enum class AuthMethod { + TOKEN, // User token + CLIENT_CERTIFICATE, // Client certificate + OPENSHIFT, // browser PKCE + OPENSHIFT_CREDENTIALS, // username/password + REDHAT_SSO // RH SSO (Sandbox) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt new file mode 100644 index 00000000..571940b8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationException.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +/** + * Exception thrown when authentication fails. + */ +class AuthenticationException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt new file mode 100644 index 00000000..a6ecdd7d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.openapi.progress.ProgressIndicator +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import io.kubernetes.client.openapi.ApiClient +import javax.swing.JPanel + +/** + * Interface for authentication strategies. + * Each strategy encapsulates the UI panel creation, authentication logic, + * and validation for a specific authentication method. + */ +interface AuthenticationStrategy { + + /** + * Returns the authentication method type for this strategy. + */ + fun getAuthMethod(): AuthMethod + + /** + * Returns the tab title for this authentication method. + */ + fun getTabTitle(): String + + /** + * Creates the UI panel for this authentication method. + */ + fun createPanel(): JPanel + + /** + * Performs the authentication process. + * + * @param selectedCluster The cluster to authenticate against + * @param server The server URL + * @param certAuthorityData The certificate authority data + * @param tlsContext The TLS context for secure connections + * @param indicator The progress indicator + * @param devSpacesContext The DevSpaces context to update + * @return true if authentication succeeded, false otherwise + */ + suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) + + /** + * Determines if the "Next" button should be enabled for this authentication method. + */ + fun isNextEnabled(): Boolean +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt new file mode 100644 index 00000000..7ad54989 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import javax.swing.event.DocumentListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.auth.tls.browseCertificate +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import javax.swing.JPanel + +/** + * Authentication strategy for client certificate authentication. + */ +class ClientCertificateAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val saveKubeconfigWithCert: suspend (Cluster, String, String, ProgressIndicator) -> Unit, + private val onFieldChanged: () -> DocumentListener +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig +) { + + val tfClientCert = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + + val tfClientKey = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + } + + override fun getAuthMethod(): AuthMethod = AuthMethod.CLIENT_CERTIFICATE + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.client_certificate") + + override fun createPanel(): JPanel = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_certificate")) { + cell(tfClientCert) + .align(Align.FILL) + .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfClientCert, "Select Client Certificate File") + } + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.client_key")) { + cell(tfClientKey) + .align(Align.FILL) + .resizableColumn() + .comment("Provide the path to a PEM file or paste the PEM content") + button("Browse...") { + browseCertificate(tfClientKey, "Select Client Key File") + } + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) { + 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 client certificate or key." + ) + + saveKubeconfigWithCert(selectedCluster, clientCertPem, clientKeyPem, indicator) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + isServerSelected() + && tfClientCert.text.isNotBlank() + && tfClientKey.text.isNotBlank() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt new file mode 100644 index 00000000..c03616ec --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import javax.swing.event.DocumentListener +import java.awt.event.KeyListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for OpenShift credentials (username/password). + */ +class OpenShiftCredentialsAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val onFieldChanged: () -> DocumentListener, + private val createEnterKeyListener: () -> KeyListener, + private val setTokenDisplay: suspend (String) -> Unit +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig +) { + + private val tfUsername = JBTextField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + private val tfPassword = JBPasswordField().apply { + document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(this) + addKeyListener(createEnterKeyListener()) + } + + private val showPasswordCheckbox = JBCheckBox( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_password") + ).apply { + isOpaque = false + background = null + addActionListener { + tfPassword.echoChar = if (isSelected) 0.toChar() else '•' + } + } + + override fun getAuthMethod(): AuthMethod = AuthMethod.OPENSHIFT_CREDENTIALS + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.credentials") + + override fun createPanel(): JPanel = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.username")) { + cell(tfUsername).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.password")) { + cell(tfPassword).align(Align.FILL) + } + row { + cell(showPasswordCheckbox) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) { + indicator.text = "Authenticating with OpenShift credentials..." + + val username = tfUsername.text + val password = String(tfPassword.password) + + val sessionManager = OpenShiftAuthSessionManager() + + val osToken = sessionManager.loginWithCredentials( + apiServerUrl = selectedCluster.url, + username = username, + password = password, + tlsContext.sslContext + ) + + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient( + server, + certAuthorityData, + finalToken.accessToken, + null, + null, + tlsContext, + "Authentication failed: invalid OpenShift credentials." + ) + + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + isServerSelected() + && tfUsername.text.isNotBlank() + && tfPassword.password?.isNotEmpty() == true +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt new file mode 100644 index 00000000..1c8d0f3e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.panel +import kotlin.coroutines.coroutineContext +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for OpenShift OAuth (browser-based PKCE). + */ +class OpenShiftOAuthAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val setTokenDisplay: suspend (String) -> Unit +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig +) { + + override fun getAuthMethod(): AuthMethod = AuthMethod.OPENSHIFT + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.openshift_oauth") + + override fun createPanel(): JPanel = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.openshift_oauth_info")) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) { + indicator.text = "Authenticating with Openshift..." + + val openshiftSSessionManager = OpenShiftAuthSessionManager() + val uri = openshiftSSessionManager.startLogin( + selectedCluster.url, + tlsContext.sslContext + ) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } + + indicator.text = "Waiting for you to complete login in your browser..." + currentCoroutineContext().ensureActive() + + indicator.text = "Obtaining OpenShift access..." + val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient( + server, + certAuthorityData, + finalToken.accessToken, + null, + null, + tlsContext, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired." + ) + + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + isServerSelected() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt new file mode 100644 index 00000000..303dd63d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.panel +import kotlin.coroutines.coroutineContext +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import javax.swing.JPanel + +/** + * Authentication strategy for Red Hat SSO (Sandbox). + */ +class RedHatSSOAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val sessionManager: RedHatAuthSessionManager +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig +) { + + override fun getAuthMethod(): AuthMethod = AuthMethod.REDHAT_SSO + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.redhat_sso") + + override fun createPanel(): JPanel = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_info")) + } + row { + label( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_token_note") + ).comment( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.pipeline_token_comment") + ) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) { + indicator.text = "Authenticating with Red Hat..." + + val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) + withContext(Dispatchers.Main) { + BrowserUtil.browse(uri) + } + + indicator.text = "Waiting for you to complete login in your browser..." + currentCoroutineContext().ensureActive() + + val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + indicator.text = "Obtaining OpenShift access..." + + val sandboxAuth = SandboxClusterAuthProvider() + val finalToken = sandboxAuth.authenticate(ssoToken) + + indicator.text = "Validating cluster access..." + + try { + val client = createValidatedApiClient( + server, certAuthorityData, + finalToken.accessToken, + null, + null, + tlsContext, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) + + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } + devSpacesContext.client = client + } catch (e: AuthenticationException) { + throw AuthenticationException("${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", e) + } + } + + override fun isNextEnabled(): Boolean = + isServerSelected() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt new file mode 100644 index 00000000..e8a0d86d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.steps.auth + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.event.DocumentListener +import java.awt.event.KeyListener +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.view.ui.PasswordFieldWithToggle +import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.tls.TlsContext +import com.redhat.devtools.gateway.openshift.Cluster +import com.redhat.devtools.gateway.util.ClipboardTokenMonitor +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Authentication strategy for token-based authentication. + */ +class TokenAuthenticationStrategy( + tfServer: Any, + saveKubeconfig: suspend (Cluster, String, ProgressIndicator) -> Unit, + private val onFieldChanged: () -> DocumentListener, + private val createEnterKeyListener: () -> KeyListener +) : AbstractAuthenticationStrategy( + tfServer, + saveKubeconfig +) { + + private val tokenFieldWithToggle = PasswordFieldWithToggle().apply { + setToggleButtonTooltip(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token")) + passwordField.document.addDocumentListener(onFieldChanged()) + PasteClipboardMenu.addTo(passwordField) + passwordField.addKeyListener(createEnterKeyListener()) + } + + val tfToken = tokenFieldWithToggle.passwordField + + val tokenSuggestionLabel = JBLabel().apply { + text = "" + foreground = JBColor.BLUE + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = false + font = font.deriveFont(Font.ITALIC or Font.PLAIN) + } + + private val clipboardMonitor = ClipboardTokenMonitor() + private var lastDetectedToken: String? = null + private var tokenLabelListener: MouseAdapter? = null + + override fun getAuthMethod(): AuthMethod = AuthMethod.TOKEN + + override fun getTabTitle(): String = + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.token") + + override fun createPanel(): JPanel = panel { + row { + cell(tokenSuggestionLabel).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { + cell(tokenFieldWithToggle).resizableColumn().align(Align.FILL) + } + } + + override suspend fun authenticate( + selectedCluster: Cluster, + server: String, + certAuthorityData: String, + tlsContext: TlsContext, + indicator: ProgressIndicator, + devSpacesContext: DevSpacesContext + ) { + indicator.text = "Validating token..." + + val token = String(tfToken.password) + + val client = createValidatedApiClient( + server, + certAuthorityData, + token, + null, + null, + tlsContext, + "Authentication failed: invalid server URL or token." + ) + + saveKubeconfig.invoke(selectedCluster, token, indicator) + devSpacesContext.client = client + } + + override fun isNextEnabled(): Boolean = + isServerSelected() + && tfToken.password?.isNotEmpty() == true + + /** + * Start monitoring clipboard for tokens. + * Should be called during initialization. + */ + fun startMonitoring(parentComponent: JComponent) { + clipboardMonitor.addListener { token -> + lastDetectedToken = token + ApplicationManager.getApplication().invokeLater( + { + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true + } + }, + ModalityState.stateForComponent(parentComponent) + ) + } + + tokenLabelListener = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + val token = lastDetectedToken ?: return + tfToken.text = token + tokenSuggestionLabel.isVisible = false + } + } + tokenSuggestionLabel.addMouseListener(tokenLabelListener) + tokenSuggestionLabel.isVisible = false + + clipboardMonitor.start() + clipboardMonitor.checkNow()?.let { token -> + lastDetectedToken = token + tokenSuggestionLabel.apply { + text = "Token detected in clipboard. Click here to use it." + isVisible = true + isEnabled = true + } + } + } + + /** + * Stop monitoring clipboard and clean up resources. + * Should be called during disposal. + */ + fun stopMonitoring() { + tokenLabelListener?.let { listener -> + tokenSuggestionLabel.removeMouseListener(listener) + } + clipboardMonitor.stop() + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt new file mode 100644 index 00000000..14fd0952 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/ui/PasswordFieldWithToggle.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view.ui + +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBPasswordField +import com.intellij.util.ui.JBUI +import java.awt.Cursor +import java.awt.Dimension +import javax.swing.JButton +import javax.swing.JLayeredPane +import javax.swing.JPanel + +/** + * A password field with an integrated show/hide toggle button. + * The eye icon button appears inside the field on the right side. + */ +class PasswordFieldWithToggle : JPanel() { + + val passwordField = JBPasswordField().apply { + margin = JBUI.insets(0, 5, 0, 30) + } + + private val toggleButton = JButton().apply { + icon = AllIcons.General.InspectionsEye + toolTipText = "Show/Hide password" + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isBorderPainted = false + isContentAreaFilled = false + isFocusPainted = false + isOpaque = false + margin = JBUI.emptyInsets() + size = Dimension(20, 20) + preferredSize = Dimension(20, 20) + minimumSize = Dimension(20, 20) + maximumSize = Dimension(20, 20) + addActionListener { + val isVisible = passwordField.echoChar == 0.toChar() + passwordField.echoChar = if (isVisible) '•' else 0.toChar() + passwordField.requestFocusInWindow() + } + } + + init { + layout = null + isOpaque = false + background = null + + // Add password field first + add(passwordField) + + // Add button directly to password field so it renders on top + passwordField.layout = null + passwordField.add(toggleButton) + } + + override fun paintComponent(g: java.awt.Graphics) { + // Don't paint background - stay transparent + } + + override fun doLayout() { + super.doLayout() + + passwordField.setBounds(0, 0, width, height) + + layoutToggle() + } + + private fun layoutToggle() { + // Position button inside password field on the right + val buttonWidth = 20 + val buttonHeight = 20 + val x = passwordField.width - buttonWidth - 5 + val y = (passwordField.height - buttonHeight) / 2 + toggleButton.setBounds(x, y, buttonWidth, buttonHeight) + } + + + override fun getPreferredSize(): Dimension { + return passwordField.preferredSize + } + + override fun getMinimumSize(): Dimension { + return passwordField.minimumSize + } + + override fun getMaximumSize(): Dimension { + return passwordField.maximumSize + } + + /** + * Set custom tooltip text for the toggle button. + */ + fun setToggleButtonTooltip(tooltip: String) { + toggleButton.toolTipText = tooltip + } +} diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index fddbaff1..dafe4052 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -5,10 +5,32 @@ 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.advanced_group=Advanced Properties +connector.wizard_step.openshift_connection.label.certificate_authority=Certificate Authority: +connector.wizard_step.openshift_connection.label.client_certificate=Client Certificate: +connector.wizard_step.openshift_connection.label.client_key=Client Key: +connector.wizard_step.openshift_connection.label.username=Username: +connector.wizard_step.openshift_connection.label.password=Password: +connector.wizard_step.openshift_connection.checkbox.save_configuration=Save configuration +connector.wizard_step.openshift_connection.checkbox.show_token=Show token +connector.wizard_step.openshift_connection.checkbox.show_password=Show password +connector.wizard_step.openshift_connection.text.openshift_oauth_info=Authenticate using OpenShift OAuth (browser login) +connector.wizard_step.openshift_connection.text.redhat_sso_info=Authenticate using Red Hat SSO (Sandbox only) +connector.wizard_step.openshift_connection.text.redhat_sso_token_note=Token will not be saved to kubeconfig +connector.wizard_step.openshift_connection.text.pipeline_token_comment=Pipeline tokens require special handling connector.wizard_step.openshift_connection.button.previous=Back connector.wizard_step.openshift_connection.button.next=Check connection +# Connection Provider's Select Cluster dialog +connector.dialog.select_cluster.title=Connect to Kubernetes Cluster + # Wizard selecting DevWorkspace step connector.wizard_step.remote_server_connection.title=Select running DevWorkspace connector.wizard_step.remote_server_connection.button.previous=Back diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt new file mode 100644 index 00000000..b53b4de9 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/CertificateSourceTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2024-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import org.junit.jupiter.api.Test +import org.assertj.core.api.Assertions.* +import java.nio.file.Paths + +class CertificateSourceTest { + + @Test + fun `#fromData creates source with isFilePath false`() { + // given + // when + val source = CertificateSource.fromData("c29tZS1kYXRh") + + // then + assertThat(source.value).isEqualTo("c29tZS1kYXRh") + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isFalse() + } + + @Test + fun `#fromPath creates source with isFilePath true`() { + // given + // when + val source = CertificateSource.fromPath("/home/user/cert.pem") + + // then + assertThat(source.value).isEqualTo("/home/user/cert.pem") + assertThat(source.isFilePath).isTrue() + assertThat(source.isModified).isFalse() + } + + @Test + fun `#fromPathOrPem detects absolute Unix path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("/home/user/.minikube/ca.crt") + + // then + assertThat(source.isFilePath).isTrue() + assertThat(source.value).isEqualTo("/home/user/.minikube/ca.crt") + } + + @Test + fun `#fromPathOrPem detects home path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("~/.kube/config") + + // then + assertThat(source.isFilePath).isTrue() + assertThat(source.value).startsWith(System.getProperty("user.home")) + } + + @Test + fun `#fromPathOrPem detects relative path starting with dot`() { + // given + // when + val source = CertificateSource.fromPathOrPem("./certs/ca.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects parent relative path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("../shared/cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects Windows path`() { + // given + // when + val source = CertificateSource.fromPathOrPem("C:\\Users\\user\\cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects simple filename`() { + // given + // when + val source = CertificateSource.fromPathOrPem("cert.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects filename without extension`() { + // given + // when + val source = CertificateSource.fromPathOrPem("mycert") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects filename with hyphens and underscores`() { + // given + // when + val source = CertificateSource.fromPathOrPem("my-cert_file.pem") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#fromPathOrPem detects PEM content`() { + // given + // when + val source = CertificateSource.fromPathOrPem("-----BEGIN CERTIFICATE-----") + + // then + assertThat(source.isFilePath).isFalse() + } + + @Test + fun `#fromPathOrPem detects long base64 string`() { + // given + val base64 = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURhekNDQWxPZ0F3SUJBZ0lVWnR4" + + // when + val source = CertificateSource.fromPathOrPem(base64) + + // then + assertThat(source.isFilePath).isFalse() + } + + @Test + fun `#fromPathOrPem marks input as modified`() { + // given + // when + val source = CertificateSource.fromPathOrPem("/path/to/cert") + + // then + assertThat(source.isModified).isTrue() + } + + @Test + fun `#fromPathOrPem trims whitespace`() { + // given + // when + val source = CertificateSource.fromPathOrPem(" /path/to/cert ") + + // then + assertThat(source.value).isEqualTo("/path/to/cert") + } + + @Test + fun `#asModified creates copy with isModified true`() { + // given + val original = CertificateSource.fromPath("/path/cert") + assertThat(original.isModified).isFalse() + + // when + val modified = original.asModified() + + // then + assertThat(modified.isModified).isTrue() + assertThat(original.isModified).isFalse() + } + + @Test + fun `#validate succeeds for base64 data`() { + // given + val source = CertificateSource.fromData("LS0tLS1CRUdJTi") + + // when/then + assertThatCode { source.validate() }.doesNotThrowAnyException() + } + + @Test + fun `#validate succeeds for existing file`() { + // given + val tempFile = java.io.File.createTempFile("test-cert", ".pem") + try { + val source = CertificateSource.fromPath(tempFile.absolutePath) + + // when + // then + assertThatCode { source.validate() }.doesNotThrowAnyException() + } finally { + tempFile.delete() + } + } + + @Test + fun `#validate throws for non-existent file`() { + // given + val source = CertificateSource.fromPath("/non/existent/cert.pem") + + // when + // then + assertThatThrownBy { source.validate() } + .isInstanceOf(java.io.FileNotFoundException::class.java) + .hasMessageContaining("/non/existent/cert.pem") + } + + @Test + fun `#fromUserInput distinguishes short alphanumeric from base64`() { + // given + // when + val source = CertificateSource.fromPathOrPem("cert") + + // then + assertThat(source.isFilePath).isTrue() + } + + @Test + fun `#toPath returns correct Path`() { + // given + val source = CertificateSource.fromPath("/home/user/cert.pem") + + // when + val path = source.toPath() + + // then + assertThat(path).isEqualTo(Paths.get("/home/user/cert.pem")) + } + + @Test + fun `#fromUserInput normalizes and base64-encodes single-line PEM`() { + // given - PEM with newlines stripped (from JBTextField paste) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIBkTCB+wIJAKHHCgVZU6T+MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwfnQ2wLuONsN9Bvj/YqYF7S2VKvwHJRwqLlqgP3uP7F3kMmZQF7VJQ/8qvwzBLvhM5Y3yLVZRZPZ8qYa/QIDAQABo1AwTjAdBgNVHQ4EFgQUqgQKqgQKqgQKqgQKqgQKqgQKMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUqgQKqgQKqgQKqgQKqgQKqgQKMA0GCSqGSIb3DQEBCwUAA4GBALiT-----END CERTIFICATE-----" + + // when + val source = CertificateSource.fromPathOrPem(singleLinePem) + + // then + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isTrue() + // Value should be base64-encoded (not raw PEM) + assertThat(source.value).doesNotContain("-----BEGIN") + assertThat(source.value).doesNotContain("-----END") + // Should be valid base64 + assertThat(source.value).matches("^[A-Za-z0-9+/=\\s]+$") + } + + @Test + fun `#fromUserInput handles properly formatted multi-line PEM`() { + // given - proper PEM with newlines + val multiLinePem = """ + -----BEGIN CERTIFICATE----- + MIIBkTCB+wIJAKHHCgVZU6T+MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxv + Y2FsaG9zdDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBaMBQxEjAQBgNV + BAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwfnQ2wLu + ONsN9Bvj/YqYF7S2VKvwHJRwqLlqgP3uP7F3kMmZQF7VJQ/8qvwzBLvhM5Y3yLVZ + RZPZ8qYa/QIDAQABo1AwTjAdBgNVHQ4EFgQUqgQKqgQKqgQKqgQKqgQKqgQKMA8G + A1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUqgQKqgQKqgQKqgQKqgQKqgQKMA0G + CSqGSIb3DQEBCwUAA4GBALiT + -----END CERTIFICATE----- + """.trimIndent() + + // when + val source = CertificateSource.fromPathOrPem(multiLinePem) + + // then + assertThat(source.isFilePath).isFalse() + assertThat(source.isModified).isTrue() + // Value should be base64-encoded + assertThat(source.value).doesNotContain("-----BEGIN") + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt new file mode 100644 index 00000000..7fa599c2 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/PemUtilsTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.Base64 + +class PemUtilsTest { + + @Test + fun `#toBase64 encodes PEM content`() { + val pem = "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUZtx\n-----END CERTIFICATE-----" + + val result = PemUtils.toBase64(pem) + + assertThat(result).isEqualTo(Base64.getEncoder().encodeToString(pem.toByteArray())) + } + + @Test + fun `#toBase64 passes through already-base64 content`() { + val base64 = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURhekNDQWxP" + + val result = PemUtils.toBase64(base64) + + assertThat(result).isEqualTo(base64) + } + + @Test + fun `#toBase64 is idempotent`() { + val pem = "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUZtx\n-----END CERTIFICATE-----" + + val encoded = PemUtils.toBase64(pem) + val doubleEncoded = PemUtils.toBase64(encoded) + + assertThat(doubleEncoded).isEqualTo(encoded) + } + + @Test + fun `#isPem returns true for PEM content`() { + assertThat(PemUtils.isPem("-----BEGIN CERTIFICATE-----\ndata")).isTrue() + } + + @Test + fun `#isPem returns true for PEM embedded in other text`() { + assertThat(PemUtils.isPem("some prefix -----BEGIN CERTIFICATE-----")).isTrue() + } + + @Test + fun `#isPem returns false for base64 content`() { + assertThat(PemUtils.isPem("LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t")).isFalse() + } + + @Test + fun `#isPem returns false for plain text`() { + assertThat(PemUtils.isPem("just some random text")).isFalse() + } + + @Test + fun `#isPem returns false for empty string`() { + assertThat(PemUtils.isPem("")).isFalse() + } + + @Test + fun `#parseCertificate handles single-line PEM from JBTextField paste`() { + // given - simulates pasting multi-line PEM into single-line JBTextField + // (newlines get stripped, resulting in single-line PEM) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(singleLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=sigstore") + assertThat(certificate.issuerX500Principal.name).contains("CN=sigstore") + } + + @Test + fun `#parseCertificate handles properly formatted multi-line PEM`() { + // given - proper PEM with newlines + val multiLinePem = """ + -----BEGIN CERTIFICATE----- + MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw + KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y + MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl + LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 + XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex + X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j + YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY + wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ + KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM + WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 + TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ + -----END CERTIFICATE----- + """.trimIndent() + + // when + val certificate = PemUtils.parseCertificate(multiLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=sigstore") + assertThat(certificate.issuerX500Principal.name).contains("CN=sigstore") + } + + @Test + fun `#parseCertificate handles single-line client certificate`() { + // given - client certificate pasted into JBTextField (newlines stripped) + val singleLinePem = "-----BEGIN CERTIFICATE-----MIIDGjCCAgKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2NhbGhvc3QtY2FAMTUzMTQ2ODA4NTAgFw0xODA3MTMwNjQ4MDVaGA8yMTE4MDYxOTA2NDgwNVowHzEdMBsGA1UEAwwUbG9jYWxob3N0QDE1MzE0NjgwODYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC9Qfx1YAEp+wrSIjbinWw3pWIDbf57LutfXgS84ilZpc7M2zeu1QrPyhCedL/gPP0QxKbPS6AR5R/DibH4RWcujL6CU5FB0Y9on+IpN/Iml2XzgGiU82gTkJg185VgWwDaHOPKvUF9N1GpvxcSvRsNGoiBJ/LlE4NhxyUQ0V/lAalYxYybxgl8/xghWMkGnQc3YKWKqGmtBaaax3xvMzamxpWPphoLG07+YZfAf0Q7vslVMmlslRmx9OpJFvRnkelbXoHHx73umbMiFp28njY8NK2dqXwb6Z80BCezppCKYpbjnupOIDAAE0KvjzhhzSS68ZgukiBZOcUlnWLzL39AgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMCUGA1UdEQQeMByCCWxvY2FsaG9zdIIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQBm9Z15QxsRqoaRDh/ELA93eE9105gwrXrR3AK/iKuJyxIc/SXVbpAaYHArMrUzaZs0GXEzgW31tZn8D3dgFy8XdZxk1ztaFTm+QTnFRogMNB8Akvpq7jwTa44c7G0wuNO2nATMu2Ifi/nSdQadTxzmZacSrevN/zcjmvSoV4VFkKO5VBnr7e1ruffxAaVAzrRraplpZvuJzlcvqTYAME8fq8H9QidvaXF6yIbEPwwUHK678W4rXn9Zp6NDuQhH0eNPAGlEAaYuyCJvJZeM68ootMi7Uh6RJOTDw1HIdekYEr1/FAg4+rH/9Gi+o/LXsBmYXabO+GjsOwfezv3THDnw-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(singleLinePem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=localhost@1531468086") + } + + @Test + fun `#parsePrivateKey handles single-line RSA private key from JBTextField paste`() { + // given - 512-bit RSA private key in single-line format (simulating JBTextField paste) + val singleLineKey = "-----BEGIN PRIVATE KEY-----MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAUEwggE9AgEAAkEA8ZF6nT2T9jLuZE6c8CduV0pc0MpCzZkLGKH4KMDjd9J5L/c/DqbrqqubGCfZhWfpn7Dccy1QEVmJf5RgLbxL6QIDAQABAkEAkphhW2jSENdJmi+mx4p2SJzFBKOptJEKjdFFAp5DrCMtOf0SgGPqadEJmVF+FcsFJKi0RXN+0Hk0JIXUoRBoXQIhAPmYugieyFBgcO17xEfe+Bm1rIBMg0DGVQVaxZmJ1LqXAiEA98QGWSQ6OTuR0U0KkgjWEyfRdtf5rOmTk+I2VL2ofX8CIQCCtQg3G2+rJ9X7h6TyPkGOtSTwyyCw+yvq8e4oyZUtYQIhAMZnMq4vVHCAQ0RXbR+D8+li+Vkxmb3dTVAe1WMGfOYBAiEA1axQLnkgglVgK9jGYUgH320TpEwLuOKQCwysxqVg9jQ=-----END PRIVATE KEY-----" + + // when + val privateKey = PemUtils.parsePrivateKey(singleLineKey) + + // then + assertThat(privateKey).isNotNull() + assertThat(privateKey.algorithm).isEqualTo("RSA") + } + + @Test + fun `#parseCertificate handles malformed PEM with only header newline`() { + // given - JBTextField sometimes preserves first newline only + val malformedPem = "-----BEGIN CERTIFICATE-----\nMIIDGjCCAgKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdsb2NhbGhvc3QtY2FAMTUzMTQ2ODA4NTAgFw0xODA3MTMwNjQ4MDVaGA8yMTE4MDYxOTA2NDgwNVowHzEdMBsGA1UEAwwUbG9jYWxob3N0QDE1MzE0NjgwODYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC9Qfx1YAEp+wrSIjbinWw3pWIDbf57LutfXgS84ilZpc7M2zeu1QrPyhCedL/gPP0QxKbPS6AR5R/DibH4RWcujL6CU5FB0Y9on+IpN/Iml2XzgGiU82gTkJg185VgWwDaHOPKvUF9N1GpvxcSvRsNGoiBJ/LlE4NhxyUQ0V/lAalYxYybxgl8/xghWMkGnQc3YKWKqGmtBaaax3xvMzamxpWPphoLG07+YZfAf0Q7vslVMmlslRmx9OpJFvRnkelbXoHHx73umbMiFp28njY8NK2dqXwb6Z80BCezppCKYpbjnupOIDAAE0KvjzhhzSS68ZgukiBZOcUlnWLzL39AgMBAAGjXDBaMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMCUGA1UdEQQeMByCCWxvY2FsaG9zdIIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQBm9Z15QxsRqoaRDh/ELA93eE9105gwrXrR3AK/iKuJyxIc/SXVbpAaYHArMrUzaZs0GXEzgW31tZn8D3dgFy8XdZxk1ztaFTm+QTnFRogMNB8Akvpq7jwTa44c7G0wuNO2nATMu2Ifi/nSdQadTxzmZacSrevN/zcjmvSoV4VFkKO5VBnr7e1ruffxAaVAzrRraplpZvuJzlcvqTYAME8fq8H9QidvaXF6yIbEPwwUHK678W4rXn9Zp6NDuQhH0eNPAGlEAaYuyCJvJZeM68ootMi7Uh6RJOTDw1HIdekYEr1/FAg4+rH/9Gi+o/LXsBmYXabO+GjsOwfezv3THDnw-----END CERTIFICATE-----" + + // when + val certificate = PemUtils.parseCertificate(malformedPem) + + // then + assertThat(certificate).isNotNull() + assertThat(certificate.subjectX500Principal.name).contains("CN=localhost@1531468086") + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt index 9429b811..dfdbd6cb 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigClusterTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -30,10 +31,25 @@ class KubeConfigClusterTest { // then assertThat(cluster).isNotNull assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") - assertThat(cluster?.certificateAuthorityData).isEqualTo("LS0tLS1CRUdJTi...") + assertThat(cluster?.certificateAuthority?.value).isEqualTo("LS0tLS1CRUdJTi...") + assertThat(cluster?.certificateAuthority?.isFilePath).isFalse() assertThat(cluster?.insecureSkipTlsVerify).isTrue() } + @Test + fun `#fromMap parses cluster with certificate-authority path`() { + val map = mapOf( + "server" to "https://api.example.com:6443", + "certificate-authority" to "/home/user/.minikube/ca.crt" + ) + + val cluster = KubeConfigCluster.fromMap(map) + + assertThat(cluster).isNotNull + assertThat(cluster?.certificateAuthority?.value).isEqualTo("/home/user/.minikube/ca.crt") + assertThat(cluster?.certificateAuthority?.isFilePath).isTrue() + } + @Test fun `#fromMap is parsing cluster with only server`() { // given @@ -47,7 +63,7 @@ class KubeConfigClusterTest { // then assertThat(cluster).isNotNull assertThat(cluster?.server).isEqualTo("https://api.example.com:6443") - assertThat(cluster?.certificateAuthorityData).isNull() + assertThat(cluster?.certificateAuthority).isNull() assertThat(cluster?.insecureSkipTlsVerify).isNull() } @@ -112,7 +128,7 @@ class KubeConfigClusterTest { // given val cluster = KubeConfigCluster( server = "https://tatooine.starwars.galaxy:6443", - certificateAuthorityData = "LS0tLS1CRUdJTi1MSUdIVF..." /* A long time ago in a galaxy far, far away... */, + certificateAuthority = CertificateSource.fromData("LS0tLS1CRUdJTi1MSUdIVF..."), insecureSkipTlsVerify = true ) @@ -127,6 +143,21 @@ class KubeConfigClusterTest { .containsEntry("insecure-skip-tls-verify", true) } + @Test + fun `#toMap writes certificate-authority path when isFilePath is true`() { + val cluster = KubeConfigCluster( + server = "https://tatooine.starwars.galaxy:6443", + certificateAuthority = CertificateSource.fromPath("/home/user/.minikube/ca.crt") + ) + + val map = cluster.toMap() + + assertThat(map) + .hasSize(2) + .containsEntry("server", "https://tatooine.starwars.galaxy:6443") + .containsEntry("certificate-authority", "/home/user/.minikube/ca.crt") + } + @Test fun `#toMap returns map with only server`() { // given @@ -142,4 +173,4 @@ class KubeConfigClusterTest { .hasSize(1) .containsEntry("server", "https://endor.starwars.galaxy:6443") } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt index 62eff46c..1fd86514 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -9,6 +9,8 @@ * Contributors: * Red Hat, Inc. - initial API and implementation */ +package com.redhat.devtools.gateway.kubeconfig + import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor @@ -65,7 +67,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 +84,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 +107,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/kubeconfig/KubeConfigUserTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt index 1c349075..151b7932 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigUserTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.kubeconfig +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -28,8 +29,8 @@ class KubeConfigUserTest { // then assertThat(user.token).isEqualTo("my-secret-token") - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -50,8 +51,10 @@ class KubeConfigUserTest { // then assertThat(user.token).isEqualTo("my-secret-token") - assertThat(user.clientCertificateData).isEqualTo("cert-data") - assertThat(user.clientKeyData).isEqualTo("key-data") + assertThat(user.clientCertificate?.value).isEqualTo("cert-data") + assertThat(user.clientCertificate?.isFilePath).isFalse() + assertThat(user.clientKey?.value).isEqualTo("key-data") + assertThat(user.clientKey?.isFilePath).isFalse() assertThat(user.username).isEqualTo("admin") assertThat(user.password).isEqualTo("secret") } @@ -59,14 +62,14 @@ class KubeConfigUserTest { @Test fun `#fromMap returns empty user for empty map`() { // given - // empty map + val user = KubeConfigUser.fromMap(emptyMap()) // when - val user = KubeConfigUser.fromMap(emptyMap()) + // then assertThat(user.token).isNull() - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -75,19 +78,20 @@ class KubeConfigUserTest { fun `#fromMap is handling non-string values gracefully`() { // given val map = mapOf( - "token" to 12345, // non-string - "client-certificate-data" to listOf("not", "string"), // non-string - "client-key-data" to true, // non-string - "username" to mapOf("not" to "string"), // non-string - "password" to 3.14 // non-string + "token" to 12345, + "client-certificate-data" to listOf("not", "string"), + "client-key-data" to true, + "username" to mapOf("not" to "string"), + "password" to 3.14 ) + // when val user = KubeConfigUser.fromMap(map) - // All should be null since they're not strings + // then assertThat(user.token).isNull() - assertThat(user.clientCertificateData).isNull() - assertThat(user.clientKeyData).isNull() + assertThat(user.clientCertificate).isNull() + assertThat(user.clientKey).isNull() assertThat(user.username).isNull() assertThat(user.password).isNull() } @@ -97,8 +101,8 @@ class KubeConfigUserTest { // given val user = KubeConfigUser( token = "DeathStar-token", - clientCertificateData = "Vader-cert", - clientKeyData = "Vader-key", + clientCertificate = CertificateSource.fromData("Vader-cert"), + clientKey = CertificateSource.fromData("Vader-key"), username = "DarthVader", password = "DarkSide" ) @@ -116,6 +120,24 @@ class KubeConfigUserTest { .containsEntry("password", "DarkSide") } + @Test + fun `#toMap returns map with certificate-authority path`() { + // given + val user = KubeConfigUser( + clientCertificate = CertificateSource.fromPath("/home/user/client.crt"), + clientKey = CertificateSource.fromPath("/home/user/client.key") + ) + + // when + val map = user.toMap() + + // then + assertThat(map) + .hasSize(2) + .containsEntry("client-certificate", "/home/user/client.crt") + .containsEntry("client-key", "/home/user/client.key") + } + @Test fun `#toMap returns map with only token`() { // given @@ -131,4 +153,4 @@ class KubeConfigUserTest { .hasSize(1) .containsEntry("token", "Rebel-Alliance-Token") } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt index 324c614e..0d98ba41 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/ClusterTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.openshift +import com.redhat.devtools.gateway.auth.tls.CertificateSource import org.junit.jupiter.api.Test import org.assertj.core.api.Assertions.* @@ -19,42 +20,27 @@ class ClusterTest { @Test fun `#Cluster constructor creates instance with name, url, and token`() { // given - val name = "death-star" - val url = "https://api.deathstar.empire" - val token = "empire-token-4ls" - // when - val cluster = Cluster(name, url, token) - + val cluster = Cluster(name = "death-star", url = "https://api.deathstar.empire", token = "empire-token-4ls") + // then - assertThat(cluster.name) - .isEqualTo(name) - assertThat(cluster.url) - .isEqualTo(url) - assertThat(cluster.token) - .isEqualTo(token) - assertThat(cluster.id) - .isEqualTo("death-star@api.deathstar.empire") + assertThat(cluster.name).isEqualTo("death-star") + assertThat(cluster.url).isEqualTo("https://api.deathstar.empire") + assertThat(cluster.token).isEqualTo("empire-token-4ls") + assertThat(cluster.id).isEqualTo("death-star@api.deathstar.empire") } @Test fun `#Cluster constructor creates instance with default null token`() { // given - val name = "tatooine" - val url = "https://api.tatooine.galaxy" - // when - val cluster = Cluster(name, url) - + val cluster = Cluster(name = "tatooine", url = "https://api.tatooine.galaxy") + // then - assertThat(cluster.name) - .isEqualTo(name) - assertThat(cluster.url) - .isEqualTo(url) - assertThat(cluster.token) - .isNull() - assertThat(cluster.id) - .isEqualTo("tatooine@api.tatooine.galaxy") + assertThat(cluster.name).isEqualTo("tatooine") + assertThat(cluster.url).isEqualTo("https://api.tatooine.galaxy") + assertThat(cluster.token).isNull() + assertThat(cluster.id).isEqualTo("tatooine@api.tatooine.galaxy") } @Test @@ -67,106 +53,83 @@ class ClusterTest { token = "solo-token-123") // then - assertThat(cluster.toString()) - .isEqualTo("millennium-falcon (https://api.falcon.ship)") + assertThat(cluster.toString()).isEqualTo("millennium-falcon (https://api.falcon.ship)") } @Test fun `#id property returns formatted id removing protocol`() { - val cluster1 = Cluster("x-wing", "https://api.xwing.rebel") - assertThat(cluster1.id) + // given + // when + assertThat(Cluster(name = "x-wing", url = "https://api.xwing.rebel").id) .isEqualTo("x-wing@api.xwing.rebel") - - val cluster2 = Cluster("tie-fighter", "http://api.tie.empire") - assertThat(cluster2.id) + assertThat(Cluster("tie-fighter", "http://api.tie.empire").id) .isEqualTo("tie-fighter@api.tie.empire") - - val cluster3 = Cluster("star-destroyer", "https://api.destroyer.empire:8443") - assertThat(cluster3.id) + assertThat(Cluster("star-destroyer", "https://api.destroyer.empire:8443").id) .isEqualTo("star-destroyer@api.destroyer.empire:8443") - - val cluster4 = Cluster("jedi-council", "https://api.jedi.temple/path") - assertThat(cluster4.id) + assertThat(Cluster("jedi-council", "https://api.jedi.temple/path").id) .isEqualTo("jedi-council@api.jedi.temple/path") } @Test fun `#equals returns true for clusters with same properties`() { // given - 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") - - // when & then - assertThat(cluster1) - .isEqualTo(cluster2) - assertThat(cluster1) - .isNotEqualTo(cluster3) + // when + val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster3 = Cluster(name = "c3po", url = "https://api.robots.galaxy", token = "droid-token-1") + + // then + assertThat(cluster1).isEqualTo(cluster2) + assertThat(cluster1).isNotEqualTo(cluster3) } @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") - - // when & then - assertThat(cluster1.hashCode()) - .isEqualTo(cluster2.hashCode()) + // when + val cluster1 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + val cluster2 = Cluster(name = "r2d2", url = "https://api.robots.galaxy", token = "droid-token-1") + + // then + assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) } @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") - + // then - assertThat(copy.name) - .isEqualTo("ben-kenobi") - assertThat(copy.url) - .isEqualTo("https://api.kenobi.jedi") - assertThat(copy.token) - .isEqualTo("kenobi-token-1") + assertThat(copy.name).isEqualTo("ben-kenobi") + assertThat(copy.url).isEqualTo("https://api.kenobi.jedi") + assertThat(copy.token).isEqualTo("kenobi-token-1") } @Test fun `#name returns url without scheme nor path`() { - val cluster1 = Cluster.fromNameAndUrl("https://jedi-temple.galaxy") - assertThat(cluster1?.name) - .isEqualTo("jedi-temple.galaxy") - - val cluster2 = Cluster.fromNameAndUrl("http://local-transport:8080") - assertThat(cluster2?.name) - .isEqualTo("local-transport-8080") - - val cluster3 = Cluster.fromNameAndUrl("https://rebel-base.galaxy:443/") - assertThat(cluster3?.name) - .isEqualTo("rebel-base.galaxy-443") - - val cluster4 = Cluster.fromNameAndUrl("https://sith-tower.galaxy:9090/api/v1") - assertThat(cluster4?.name) - .isEqualTo("sith-tower.galaxy-9090") + // given + // when + // then + assertThat(Cluster.fromNameAndUrl("https://jedi-temple.galaxy")?.name).isEqualTo("jedi-temple.galaxy") + assertThat(Cluster.fromNameAndUrl("http://local-transport:8080")?.name).isEqualTo("local-transport-8080") + assertThat(Cluster.fromNameAndUrl("https://rebel-base.galaxy:443/")?.name).isEqualTo("rebel-base.galaxy-443") + assertThat(Cluster.fromNameAndUrl("https://sith-tower.galaxy:9090/api/v1")?.name).isEqualTo("sith-tower.galaxy-9090") } @Test fun `#id returns name@url without scheme, port nor path`() { - val cluster1 = Cluster("x-wing", "https://api.xwing.rebel") - assertThat(cluster1.id) + // given + // when + assertThat(Cluster(name = "x-wing", url = "https://api.xwing.rebel").id) .isEqualTo("x-wing@api.xwing.rebel") - - val cluster2 = Cluster("tie-fighter", "http://localhost:8080") - assertThat(cluster2.id) + assertThat(Cluster("tie-fighter", "http://localhost:8080").id) .isEqualTo("tie-fighter@localhost:8080") - - val cluster3 = Cluster("star-destroyer", "https://api.destroyer.empire:443/") - assertThat(cluster3.id) + assertThat(Cluster("star-destroyer", "https://api.destroyer.empire:443/").id) .isEqualTo("star-destroyer@api.destroyer.empire:443/") - - val cluster4 = Cluster("jedi-council", "https://api.jedi.temple:9090/api/v1") - assertThat(cluster4.id) + assertThat(Cluster("jedi-council", "https://api.jedi.temple:9090/api/v1").id) .isEqualTo("jedi-council@api.jedi.temple:9090/api/v1") } @@ -179,13 +142,9 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(url) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.url) - .isEqualTo(url) - assertThat(cluster?.name) - .isNotNull() - .isNotEmpty() + assertThat(cluster).isNotNull() + assertThat(cluster?.url).isEqualTo(url) + assertThat(cluster?.name).isNotNull().isNotEmpty() } @Test @@ -199,12 +158,9 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(input) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.name) - .isEqualTo(name) - assertThat(cluster?.url) - .isEqualTo(url) + assertThat(cluster).isNotNull() + assertThat(cluster?.name).isEqualTo(name) + assertThat(cluster?.url).isEqualTo(url) } @Test @@ -216,9 +172,114 @@ class ClusterTest { val cluster = Cluster.fromNameAndUrl(url) // then - assertThat(cluster) - .isNotNull() - assertThat(cluster?.url) - .isEqualTo(url) + assertThat(cluster).isNotNull() + assertThat(cluster?.url).isEqualTo(url) + } + + @Test + fun `#Cluster constructor creates instance with client certificate authentication`() { + // given + val name = "yavin" + val url = "https://api.yavin.rebel" + + // when + val cluster = Cluster( + name = name, + url = url, + clientCert = CertificateSource.fromData("cert-data"), + clientKey = CertificateSource.fromData("key-data") + ) + + // then + assertThat(cluster.name).isEqualTo(name) + assertThat(cluster.url).isEqualTo(url) + assertThat(cluster.token).isNull() + assertThat(cluster.clientCert?.value).isEqualTo("cert-data") + assertThat(cluster.clientKey?.value).isEqualTo("key-data") + } + + @Test + fun `#Cluster constructor allows token-only authentication`() { + // given + // when + val cluster = Cluster( + name = "scarif", + url = "https://api.scarif.empire", + token = "empire-token" + ) + + // then + assertThat(cluster.token).isEqualTo("empire-token") + assertThat(cluster.clientCert).isNull() + assertThat(cluster.clientKey).isNull() + } + + @Test + fun `#Cluster constructor fails when both token and client certificate are provided`() { + // given + // when + assertThatThrownBy { + Cluster( + name = "mustafar", + url = "https://api.mustafar.sith", + token = "vader-token", + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#Cluster constructor fails when certificate is provided without key`() { + // given + // when + assertThatThrownBy { + Cluster( + name = "kamino", + url = "https://api.kamino.cloners", + clientCert = CertificateSource.fromData("cert-only") + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#Cluster constructor fails when key is provided without certificate`() { + // given + // when + assertThatThrownBy { + Cluster( + name = "geonosis", + url = "https://api.geonosis.droids", + clientKey = CertificateSource.fromData("key-only") + ) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `#equals and hashCode include client certificate fields`() { + // given + val cluster1 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") + ) + val cluster2 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + clientCert = CertificateSource.fromData("cert"), + clientKey = CertificateSource.fromData("key") + ) + val cluster3 = Cluster( + name = "endor", + url = "https://api.endor.rebel", + token = "ewok-token" + ) + + // when + // then + assertThat(cluster1).isEqualTo(cluster2) + assertThat(cluster1.hashCode()).isEqualTo(cluster2.hashCode()) + assertThat(cluster1).isNotEqualTo(cluster3) } } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt new file mode 100644 index 00000000..5eb19ced --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/util/ClipboardTokenMonitorTest.kt @@ -0,0 +1,489 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +class ClipboardTokenMonitorTest { + + private lateinit var monitor: ClipboardTokenMonitor + private lateinit var mockClipboard: MockClipboardReader + private val detectedTokens = mutableListOf() + + @BeforeEach + fun beforeEach() { + mockClipboard = MockClipboardReader() + monitor = ClipboardTokenMonitor( + pollingIntervalMs = 100, + clipboardReader = mockClipboard + ) + detectedTokens.clear() + } + + @AfterEach + fun afterEach() { + monitor.stop() + } + + /** + * Mock clipboard reader for testing without requiring a display server + */ + private class MockClipboardReader : ClipboardReader { + private var content: String? = null + + override fun readText(): String? = content + + fun setContent(text: String?) { + content = text + } + } + + @Test + fun `#isOpenShiftToken returns true for valid token format`() { + // given + val validToken = "sha256~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(validToken) + + // then + assertThat(result).isTrue + } + + @Test + fun `#isOpenShiftToken returns true for token with underscores and dashes`() { + // given + val validToken = "sha256~ABC_DEF-GHI_JKL-MNO_PQR" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(validToken) + + // then + assertThat(result).isTrue + } + + @Test + fun `#isOpenShiftToken returns false for token without prefix`() { + // given + val invalidToken = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token with wrong prefix`() { + // given + val invalidToken = "sha512~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token too short`() { + // given + val invalidToken = "sha256~ABC" // Less than 20 chars after prefix + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for token with invalid characters`() { + // given + val invalidToken = "sha256~ABCDEFGHIJKLMNOPQRST@#$%" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(invalidToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for null token`() { + // given + val nullToken: String? = null + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(nullToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken returns false for empty token`() { + // given + val emptyToken = "" + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(emptyToken) + + // then + assertThat(result).isFalse + } + + @Test + fun `#isOpenShiftToken trims whitespace before validation`() { + // given + val tokenWithWhitespace = " sha256~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop " + + // when + val result = ClipboardTokenMonitor.isOpenShiftToken(tokenWithWhitespace) + + // then + assertThat(result).isTrue + } + + @Test + fun `#checkNow returns token when valid token is in clipboard`() { + runBlocking { + // given + val validToken = "sha256~ValidTokenWith20PlusCharacters" + setClipboardContent(validToken) + delay(50.milliseconds) // Give clipboard time to update + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isEqualTo(validToken) + } + } + + @Test + fun `#checkNow returns null when invalid token is in clipboard`() { + runBlocking { + // given + setClipboardContent("not a valid token") + delay(50.milliseconds) + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isNull() + } + } + + @Test + fun `#checkNow returns null when clipboard is empty`() { + runBlocking { + // given + setClipboardContent("") + delay(50.milliseconds) + + // when + val result = monitor.checkNow() + + // then + assertThat(result).isNull() + } + } + + @Test + fun `#checkNow does not interfere with monitoring`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val monitoringToken = "sha256~MonitoringToken123456789" + val checkNowToken = "sha256~CheckNowToken1234567890" + + // when + monitor.start() + setClipboardContent(checkNowToken) + val immediateResult = monitor.checkNow() + delay(100.milliseconds) + setClipboardAndWaitForDetection(monitoringToken) + + // then + assertThat(immediateResult).isEqualTo(checkNowToken) + assertThat(detectedTokens).contains(checkNowToken, monitoringToken) + } + } + + @Test + fun `#addListener registers listener successfully`() { + // given + val listener = ClipboardTokenMonitor.TokenDetectedListener { token -> + detectedTokens.add(token) + } + + // when + monitor.addListener(listener) + + // then - no exception thrown, listener is registered + assertThat(detectedTokens).isEmpty() + } + + @Test + fun `#removeListener removes listener successfully`() { + // given + val listener = ClipboardTokenMonitor.TokenDetectedListener { token -> + detectedTokens.add(token) + } + monitor.addListener(listener) + + // when + monitor.removeListener(listener) + + // then - no exception thrown, listener is removed + assertThat(detectedTokens).isEmpty() + } + + @Test + fun `#start begins monitoring clipboard`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val validToken = "sha256~MonitoringTestToken12345678" + + // when + monitor.start() + delay(150.milliseconds) // Wait less than one poll interval + setClipboardAndWaitForDetection(validToken) + + // then + assertThat(detectedTokens).contains(validToken) + } + } + + @Test + fun `#start does not start multiple polling jobs`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + + // when + monitor.start() + monitor.start() // Start again + monitor.start() // And again + + val validToken = "sha256~MultipleStartTestToken123" + setClipboardAndWaitForDetection(validToken) + + // then - should only be notified once per token change + assertThat(detectedTokens.filter { it == validToken }).hasSize(1) + } + } + + @Test + fun `#stop halts monitoring`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + monitor.start() + delay(100.milliseconds) + + // when + monitor.stop() + val validToken = "sha256~AfterStopTestToken1234567" + setClipboardAndWaitForDetection(validToken) + + // then - no tokens should be detected after stop + assertThat(detectedTokens).doesNotContain(validToken) + } + } + + @Test + fun `listener notified only once per unique token`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val validToken = "sha256~UniqueTokenTest123456789" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + // Don't change clipboard content + delay(250.milliseconds) + + // then - should only be notified once + assertThat(detectedTokens.filter { it == validToken }).hasSize(1) + } + } + + @Test + fun `listener notified for each different token`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val token1 = "sha256~FirstTokenTest1234567890" + val token2 = "sha256~SecondTokenTest123456789" + + // when + monitor.start() + setClipboardAndWaitForDetection(token1) + setClipboardAndWaitForDetection(token2) + + // then - should be notified for both + assertThat(detectedTokens).contains(token1, token2) + } + } + + @Test + fun `multiple listeners all receive notifications`() { + runBlocking { + // given + val tokens1 = mutableListOf() + val tokens2 = mutableListOf() + monitor.addListener { token -> tokens1.add(token) } + monitor.addListener { token -> tokens2.add(token) } + val validToken = "sha256~MultiListenerTest12345678" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + + // then - both listeners should receive the token + assertThat(tokens1).contains(validToken) + assertThat(tokens2).contains(validToken) + } + } + + @Test + fun `listener exception does not break other listeners`() { + runBlocking { + // given + val tokens = mutableListOf() + monitor.addListener { throw RuntimeException("Listener failure") } + monitor.addListener { token -> tokens.add(token) } + val validToken = "sha256~ExceptionTestToken1234567" + + // when + monitor.start() + setClipboardAndWaitForDetection(validToken) + + // then - second listener should still receive notification + assertThat(tokens).contains(validToken) + } + } + + @Test + fun `monitor ignores non-token clipboard content`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + + // when + monitor.start() + setClipboardContent("Just some regular text") + delay(250.milliseconds) + setClipboardContent("12345") + delay(250.milliseconds) + setClipboardContent("sha256~short") // Too short + delay(250.milliseconds) + + // then - no tokens should be detected + assertThat(detectedTokens).isEmpty() + } + } + + @Test + fun `monitor handles rapid clipboard changes`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val tokens = (1..5).map { "sha256~RapidChangeToken${it}234567890" } + + // when + monitor.start() + tokens.forEach { token -> + setClipboardContent(token) + delay(150.milliseconds) // Faster than polling interval + } + delay(300.milliseconds) // Let polling catch up + + // then - should detect at least some tokens (may not catch all due to rapid changes) + assertThat(detectedTokens).isNotEmpty() + tokens.forEach { token -> + if (detectedTokens.contains(token)) { + assertThat(detectedTokens.filter { it == token }).hasSize(1) + } + } + } + } + + @Test + fun `#stop can be called multiple times safely`() { + // when + monitor.stop() + monitor.stop() + monitor.stop() + + // then - no exception thrown + assertThat(true).isTrue + } + + @Test + fun `monitor can be restarted after stop`() { + runBlocking { + // given + monitor.addListener { token -> detectedTokens.add(token) } + val token1 = "sha256~FirstRunToken1234567890" + val token2 = "sha256~SecondRunToken123456789" + + // when - first run + startSetAndStop(token1) + + // when - second run + monitor.start() + setClipboardAndWaitForDetection(token2) + + // then - both tokens should be detected + assertThat(detectedTokens).contains(token1, token2) + } + } + + /** + * Helper to set clipboard content for testing + */ + private fun setClipboardContent(text: String) { + mockClipboard.setContent(text) + } + + /** + * Helper to set clipboard content and wait for detection + */ + private suspend fun setClipboardAndWaitForDetection(content: String, delayMs: Long = 250) { + setClipboardContent(content) + delay(delayMs.milliseconds) + } + + /** + * Helper to start monitor, set clipboard content, wait for detection, and stop + */ + private suspend fun startSetAndStop(content: String, detectionDelayMs: Long = 250, stopDelayMs: Long = 100) { + monitor.start() + setClipboardContent(content) + delay(detectionDelayMs.milliseconds) + monitor.stop() + delay(stopDelayMs.milliseconds) + } +}