diff --git a/build.gradle.kts b/build.gradle.kts index 75bc0753..a22f5db8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,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() @@ -68,6 +69,17 @@ dependencies { implementation("io.kubernetes:client-java:25.0.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.20.1") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1") + + // RedHat Auth dependencies + implementation("io.ktor:ktor-server-core-jvm:2.3.7") + implementation("io.ktor:ktor-server-netty-jvm:2.3.7") + implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.7") + + implementation("com.nimbusds:oauth2-oidc-sdk:11.15") // Core OIDC/OAuth2 + implementation("com.nimbusds:nimbus-jose-jwt:9.37") // JWT processing + + // JSON serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } // Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/AuthCodeFlow.kt new file mode 100644 index 00000000..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..2939029e --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/IdeaSecureTokenStorage.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + +package com.redhat.devtools.gateway.auth.code + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class IdeaSecureTokenStorage : SecureTokenStorage { + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private val attributes = CredentialAttributes( + "com.redhat.devtools.gateway.auth.sso" + ) + + override suspend fun saveToken(token: TokenModel) { + val serialized = json.encodeToString(token) + + PasswordSafe.instance.set( + attributes, + Credentials("sso", serialized) + ) + } + + override suspend fun loadToken(): TokenModel? { + val credentials = PasswordSafe.instance.get(attributes) + ?: return null + + val raw = credentials.password?.toString() + ?: return null + + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } + + override suspend fun clearToken() { + PasswordSafe.instance.set(attributes, null) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt new file mode 100644 index 00000000..5b42c8f0 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/JBPasswordSafeTokenStorage.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class JBPasswordSafeTokenStorage : SecureTokenStorage { + + private val attributes = CredentialAttributes( + generateServiceName( + "RedHatGatewayPlugin", + "RedHatAuthToken" + ) + ) + + override suspend fun saveToken(token: TokenModel) { + val json = Json.encodeToString(token) + + val credentials = Credentials( + "redhat", + json + ) + + PasswordSafe.instance.set(attributes, credentials) + } + + override suspend fun loadToken(): TokenModel? { + val credentials = PasswordSafe.instance.get(attributes) ?: return null + val json = credentials.getPasswordAsString() ?: return null + return Json.decodeFromString(json) + } + + override suspend fun clearToken() { + PasswordSafe.instance.set(attributes, null) + } +} + diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt new file mode 100644 index 00000000..38efe3ba --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/OpenShiftAuthCodeFlow.kt @@ -0,0 +1,276 @@ +/* + * 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.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.* + +/** + * 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) +) : AuthCodeFlow { + + private lateinit var codeVerifier: CodeVerifier + private lateinit var state: State + + private lateinit var metadata: OAuthMetadata + + private val json = Json { ignoreUnknownKeys = true } + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + @Serializable + private data class OAuthMetadata( + val issuer: String, + + @SerialName("authorization_endpoint") + val authorizationEndpoint: String, + + @SerialName("token_endpoint") + val tokenEndpoint: String + ) + + /** + * Discover OAuth endpoints from the cluster. + */ + private suspend fun discoverOAuthMetadata(): OAuthMetadata { + val url = "$apiServerUrl/.well-known/oauth-authorization-server" + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + error("OAuth discovery failed: ${response.statusCode()}\n${response.body()}") + } + + return json.decodeFromString(OAuthMetadata.serializer(), response.body()) + } + + override suspend fun startAuthFlow(): AuthCodeRequest { + metadata = discoverOAuthMetadata() + codeVerifier = CodeVerifier() + state = State() + + val request = AuthorizationRequest.Builder( + ResponseType.CODE, + ClientID("openshift-cli-client") // same as oc + ) + .endpointURI(URI(metadata.authorizationEndpoint)) + .redirectionURI(redirectUri) + .codeChallenge(codeVerifier, CodeChallengeMethod.S256) + .build() + + return AuthCodeRequest( + authorizationUri = request.toURI(), + codeVerifier = codeVerifier, + nonce = Nonce() + ) + } + + @Serializable + data class AccessTokenResponseJson( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long + ) + + override suspend fun handleCallback(parameters: Parameters): SSOToken { + val code: String = parameters["code"] ?: error("Missing 'code' parameter in callback") + + 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 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.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + error("Token request failed: ${response.statusCode()}\n${response.body()}") + } + + val token = json.decodeFromString(AccessTokenResponseJson.serializer(), response.body()) + val expiresAt = + if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + + return SSOToken( + accessToken = token.accessToken, + idToken = "", + 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 = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + + val redirectUri = URI( + metadata.tokenEndpoint.replace( + "/oauth/token", + "/oauth/token/implicit" + ) + ) + + val authorizeUri = AuthorizationRequest.Builder( + ResponseType.CODE, + ClientID("openshift-challenging-client") + ) + .endpointURI(URI(metadata.authorizationEndpoint)) + .redirectionURI(redirectUri) + .build() + .toURI() + + val basicAuth = "Basic " + Base64.getEncoder() + .encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8)) + + // First request (expect 401) + var request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("X-Csrf-Token", "1") + .GET() + .build() + + var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + + // Retry with Basic auth + if (response.statusCode() == 401) { + request = HttpRequest.newBuilder() + .uri(authorizeUri) + .header("Authorization", basicAuth) + .header("X-Csrf-Token", "1") + .GET() + .build() + + response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()) + } + + if (response.statusCode() !in listOf(302, 303)) { + error("Authorization failed: ${response.statusCode()}") + } + + val location = response.headers().firstValue("Location") + .orElseThrow { error("Missing redirect Location header") } + val redirectedUri = URI(location) + val query = redirectedUri.query ?: error("Missing query in redirect") + val params = query.split("&") + .map { it.split("=", limit = 2) } + .associate { it[0] to URLDecoder.decode(it[1], StandardCharsets.UTF_8) } + + val code = params["code"] ?: error("Authorization code not found in redirect") + + val token = exchangeCodeForTokenWithBasicAuth(code = code, redirectUri = redirectUri) + + return SSOToken( + accessToken = token.accessToken, + idToken = token.idToken, + accountLabel = username, + expiresAt = token.expiresAt + ) + } + + private suspend fun exchangeCodeForTokenWithBasicAuth( + code: String, + redirectUri: URI + ): SSOToken { + val clientAuth = "Basic " + Base64.getEncoder() + .encodeToString("openshift-challenging-client:".toByteArray(StandardCharsets.UTF_8)) + + val form = encodeForm( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + val request = HttpRequest.newBuilder() + .uri(URI(metadata.tokenEndpoint)) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuth) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + error("Token exchange failed: ${response.statusCode()} ${response.body()}") + } + + val token = json.decodeFromString( + AccessTokenResponseJson.serializer(), + response.body() + ) + val expiresAt = if (token.expiresIn > 0) System.currentTimeMillis() + token.expiresIn * 1000 else null + + return SSOToken( + accessToken = token.accessToken, + idToken = "", + accountLabel = "", + expiresAt = expiresAt + ) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt new file mode 100644 index 00000000..73a64217 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/RedHatAuthCodeFlow.kt @@ -0,0 +1,149 @@ +/* + * 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.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * RedHat SSO OAuth Flow. + * Creates and returns a Service Account pipeline token limited only for Sandboxed clusters + */ +class RedHatAuthCodeFlow( + private val clientId: String, + private val redirectUri: URI, + private val providerMetadata: OIDCProviderMetadata +) : AuthCodeFlow { + + private lateinit var codeVerifier: CodeVerifier + private lateinit var nonce: Nonce + private lateinit var state: State + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + override suspend fun startAuthFlow(): AuthCodeRequest { + codeVerifier = CodeVerifier() + nonce = Nonce() + state = State() + + val request = AuthenticationRequest.Builder( + ResponseType.CODE, + Scope("openid", "profile", "email"), + ClientID(clientId), + redirectUri + ) + .endpointURI(providerMetadata.authorizationEndpointURI) + .codeChallenge(codeVerifier, CodeChallengeMethod.S256) + .nonce(nonce) + .state(state) + .build() + + return AuthCodeRequest( + authorizationUri = request.toURI(), + codeVerifier = codeVerifier, + nonce = nonce + ) + } + + override suspend fun handleCallback(parameters: Parameters): SSOToken { + val code = parameters["code"] ?: error("Missing 'code' parameter in callback") + + fun encodeForm(vararg pairs: Pair): String = + pairs.joinToString("&") { (k, v) -> + "${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}" + } + + val form = encodeForm( + "grant_type" to "authorization_code", + "client_id" to clientId, + "code" to code, + "redirect_uri" to redirectUri.toString(), + "code_verifier" to codeVerifier.value + ) + + val basicAuth = "Basic " + Base64.getEncoder() + .encodeToString("$clientId:".toByteArray(StandardCharsets.UTF_8)) + + val request = HttpRequest.newBuilder() + .uri(providerMetadata.tokenEndpointURI) + .header("Authorization", basicAuth) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + error("Token request failed: ${response.statusCode()}\n${response.body()}") + } + + val json = Json { ignoreUnknownKeys = true } + val body = json.parseToJsonElement(response.body()).jsonObject + + val accessToken = body["access_token"]?.jsonPrimitive?.content + ?: error("Missing access_token in token response") + + val idToken = body["id_token"]?.jsonPrimitive?.content.orEmpty() + val expiresInSeconds = body["expires_in"]?.jsonPrimitive?.longOrNull ?: 3600 + val accountLabel = if (idToken.isNotBlank()) { + try { + val jwt = JWTParser.parse(idToken) as SignedJWT + val claims = jwt.jwtClaimsSet + + claims.getStringClaim("preferred_username") + ?: claims.getStringClaim("email") + ?: "unknown-user" + } catch (e: Exception) { + "unknown-user" + } + } else { + "unknown-user" + } + + return SSOToken( + accessToken = accessToken, + idToken = idToken, + expiresAt = System.currentTimeMillis() + expiresInSeconds * 1000, + accountLabel = accountLabel + ) + } + + override suspend fun login(parameters: Parameters): SSOToken = + error( + "Direct login is not supported for Red Hat SSO authentication. " + + "This flow requires browser-based authentication via startAuthFlow(), " + + "followed by token exchange with the Sandbox API." + ) +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt new file mode 100644 index 00000000..97290403 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/code/SecureTokenStorage.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.code + +interface SecureTokenStorage { + suspend fun saveToken(token: TokenModel) + suspend fun loadToken(): TokenModel? + suspend fun clearToken() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt new file mode 100644 index 00000000..fa48d0c8 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.config + +data class AuthConfig( + val serviceId: String = "redhat-account-auth", + + val authUrl: String = + System.getenv("REDHAT_SSO_URL") + ?: "https://sso.redhat.com/auth/realms/redhat-external/", + + val apiUrl: String = + System.getenv("KAS_API_URL") + ?: "https://api.openshift.com", + + val clientId: String = + System.getenv("CLIENT_ID") + ?: "vscode-redhat-account", + + val deviceCodeOnly: Boolean = + System.getenv("DEVICE_CODE_ONLY") + ?.equals("true", ignoreCase = true) + ?: false, + + val authType: AuthType = AuthType.SSO_REDHAT +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt new file mode 100644 index 00000000..da7a8249 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/AuthType.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.config + +enum class AuthType(val value: String) { + SSO_REDHAT("sso-redhat"), + SSO_OPENSHIFT("sso-openshift") +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt new file mode 100644 index 00000000..08a97c35 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/config/ServerConfig.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.config + +data class ServerConfig( + /** + * Path relative to externalUrl + * Example: sso-redhat-callback + */ + val callbackPath: String, + + /** + * Fully qualified external base URL + * Examples: + * - http://localhost + * - https://workspace-id.openshiftapps.com + */ + val externalUrl: String, + + /** + * Local listening port (optional, dynamic if null) + */ + val port: Int? = null +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt new file mode 100644 index 00000000..ccb710d4 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.oidc + +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata + +class OidcProviderMetadataResolver( + authUrl: String +) { + private val issuer = Issuer(authUrl) + + @Volatile + private var cached: OIDCProviderMetadata? = null + + suspend fun resolve(): OIDCProviderMetadata { + cached?.let { return it } + + val request = OIDCProviderConfigurationRequest(issuer) + val httpResponse = request.toHTTPRequest().send() + val metadata = OIDCProviderMetadata.parse(httpResponse.bodyAsJSONObject) + + cached = metadata + return metadata + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt new file mode 100644 index 00000000..2df90d1b --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxApi.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.sandbox + +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +class SandboxApi( + private val baseUrl: String, + private val timeoutMs: Long +) { + + private val httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + private val json = Json { + ignoreUnknownKeys = true + } + + fun getSignUpStatus(ssoToken: String): SandboxSignupResponse? { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .GET() + .build() + + val response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ) + + if (response.statusCode() != 200) { + return null + } + + return json.decodeFromString(response.body()) + } + + fun signUp(ssoToken: String): Boolean { + val request = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/api/v1/signup")) + .header("Authorization", "Bearer $ssoToken") + .POST(HttpRequest.BodyPublishers.noBody()) + .build() + + val response = httpClient.send( + request, + HttpResponse.BodyHandlers.discarding() + ) + + return response.statusCode() in 200..299 + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt new file mode 100644 index 00000000..e84ffdf1 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxClusterAuthProvider.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.sandbox + +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.TokenModel +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.V1ObjectMeta +import io.kubernetes.client.openapi.models.V1Secret +import io.kubernetes.client.openapi.models.V1ServiceAccount +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.util.credentials.AccessTokenAuthentication +import java.util.* +import java.util.concurrent.TimeUnit + +class SandboxClusterAuthProvider( + private val sandboxApi: SandboxApi = SandboxApi( + SandboxDefaults.SANDBOX_API_BASE_URL, + SandboxDefaults.SANDBOX_API_TIMEOUT_MS + ) +) { + fun authenticate(ssoToken: SSOToken): TokenModel { + val signup = sandboxApi.getSignUpStatus(ssoToken.idToken) + ?: error("Sandbox not available") + + if (!signup.status.ready) error("Sandbox not ready") + + val username = signup.compliantUsername ?: signup.username + val namespace = "$username-dev" + + val client: ApiClient = ClientBuilder.standard() + .setBasePath(signup.proxyUrl!!) + .setAuthentication(AccessTokenAuthentication(ssoToken.idToken)) + .build() + .also { it.httpClient = it.httpClient.newBuilder().readTimeout(30, TimeUnit.SECONDS).build() } + + val coreV1Api = CoreV1Api(client) + val pipelineSA = ensurePipelineServiceAccount(coreV1Api, namespace) + val pipelineSecret = ensurePipelineTokenSecret(coreV1Api, namespace, pipelineSA) + val pipelineToken = extractToken(pipelineSecret) + + return TokenModel( + accessToken = pipelineToken, + expiresAt = null, // non-expiring pipeline token + accountLabel = ssoToken.accountLabel, + kind = AuthTokenKind.PIPELINE, + clusterApiUrl = signup.apiEndpoint, + namespace = namespace, + serviceAccount = "pipeline" + ) + } + + private fun ensurePipelineServiceAccount(api: CoreV1Api, namespace: String): V1ServiceAccount { + val saList = api.listNamespacedServiceAccount(namespace).execute() + ?: error("Failed to list ServiceAccounts") + + return saList.items.firstOrNull { it.metadata?.name == "pipeline" } + ?: api.createNamespacedServiceAccount( + namespace, + V1ServiceAccount().metadata(V1ObjectMeta().name("pipeline")) + ).execute() ?: error("Failed to create pipeline ServiceAccount") + } + + private fun ensurePipelineTokenSecret(api: CoreV1Api, namespace: String, sa: V1ServiceAccount): V1Secret { + val secretName = "pipeline-secret-${sa.metadata?.name}" + val secretList = api.listNamespacedSecret(namespace).execute() + ?: error("Failed to list Secrets") + + secretList.items.firstOrNull { it.metadata?.name == secretName && it.data?.containsKey("token") == true } + ?.let { return it } + + val secret = V1Secret().metadata( + V1ObjectMeta() + .name(secretName) + .putAnnotationsItem("kubernetes.io/service-account.name", sa.metadata!!.name) + .putAnnotationsItem("kubernetes.io/service-account.uid", sa.metadata!!.uid) + ).type("kubernetes.io/service-account-token") + + api.createNamespacedSecret(namespace, secret).execute() + + repeat(30) { + val s = api.readNamespacedSecret(secretName, namespace).execute() + if (s.data?.containsKey("token") == true) return s + Thread.sleep(1000) + } + + error("Pipeline token secret not populated") + } + + private fun extractToken(secret: V1Secret): String { + val tokenBytes = secret.data?.get("token") ?: error("Token missing in secret") + return String(tokenBytes, Charsets.UTF_8) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt new file mode 100644 index 00000000..80d691bc --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxDefaults.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.sandbox + +object SandboxDefaults { + + /** + * Matches VS Code default: + * openshiftToolkit.sandboxApiHostUrl + */ + const val SANDBOX_API_BASE_URL = + "https://registration-service-toolchain-host-operator.apps.sandbox.x8i5.p1.openshiftapps.com" + + /** + * Matches VS Code default: + * openshiftToolkit.sandboxApiTimeout + */ + const val SANDBOX_API_TIMEOUT_MS: Long = 100_000 +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt new file mode 100644 index 00000000..d208338d --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/sandbox/SandboxModels.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.sandbox + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SandboxSignupResponse( + @SerialName("apiEndpoint") + val apiEndpoint: String, + + @SerialName("proxyURL") + val proxyUrl: String? = null, + + @SerialName("clusterName") + val clusterName: String? = null, + + @SerialName("consoleURL") + val consoleUrl: String? = null, + + @SerialName("username") + val username: String, + + @SerialName("compliantUsername") + val compliantUsername: String? = null, + + @SerialName("status") + val status: SandboxStatus +) + +@Serializable +data class SandboxStatus( + val ready: Boolean, + + @SerialName("verificationRequired") + val verificationRequired: Boolean = false, + + val reason: String? = null +) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/server/CallbackServer.kt new file mode 100644 index 00000000..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/AuthSessionListener.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt new file mode 100644 index 00000000..a1a8d339 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.session + +interface AuthSessionListener { + fun sessionChanged() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt new file mode 100644 index 00000000..d8a47d02 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.session + +import com.redhat.devtools.gateway.auth.code.SSOToken +import java.net.URI + +interface AuthSessionManager { + + /** Called once on plugin startup to load any existing token. */ + suspend fun initialize() + + /** Starts login and returns browser URL */ + suspend fun startLogin(apiServerUrl: String? = null): URI + + /** Returns a valid (non-expired) token or null. Refreshes automatically if possible. */ + suspend fun getValidToken(): SSOToken? + + /** Clears session and stored tokens. */ + suspend fun logout() + + /** Returns true if a session is active. */ + fun isLoggedIn(): Boolean + + /** Returns the current account label, if logged in. */ + fun currentAccount(): String? +} \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt new file mode 100644 index 00000000..742f9179 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.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.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.components.Service +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.Parameters +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.server.CallbackServer +import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer +import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder +import com.redhat.devtools.gateway.auth.server.ServerConfigProvider +import kotlinx.coroutines.* +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean + +const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class OpenShiftAuthSessionManager : AuthSessionManager { + + private val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_OPENSHIFT) // or another type if you distinguish + } + + private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private lateinit var authFlow: OpenShiftAuthCodeFlow + + private val listeners = mutableSetOf() + private var currentToken: SSOToken? = null + private val loginInProgress = AtomicBoolean(false) + private var pendingLogin: CompletableDeferred? = null + + fun isLoginInProgress(): Boolean = loginInProgress.get() + + fun addListener(listener: AuthSessionListener) { + listeners += listener + } + + fun removeListener(listener: AuthSessionListener) { + listeners -= listener + } + + private fun notifyChanged() { + listeners.forEach { it.sessionChanged() } + } + + override suspend fun initialize() { + notifyChanged() + } + + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") + return try { + withTimeout(timeoutMs) { deferred.await() } + } catch (_: TimeoutCancellationException) { + throw SsoLoginException.Timeout + } + } + + override suspend fun startLogin(apiServerUrl: String?): URI { + if (apiServerUrl == null) { + throw IllegalStateException("Provide API Server URL") + } + + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + pendingLogin = CompletableDeferred() + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port) + ) + + val request = authFlow.startAuthFlow() + + CoroutineScope(Dispatchers.IO).launch { + try { + val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) + if (params == null) { + pendingLogin?.completeExceptionally(SsoLoginException.Timeout) + notifyLoginCancelled() + return@launch + } + + val token: SSOToken = authFlow.handleCallback(params) + currentToken = token + pendingLogin?.complete(token) + + } catch (e: Exception) { + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "OpenShift login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + private suspend fun cancelLogin() { + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + private fun notifyLoginCancelled() { + Notifications.Bus.notify( + Notification( + "OpenShift Authentication", + "Login cancelled", + "You closed the browser or the login timed out.", + NotificationType.INFORMATION + ) + ) + } + + override suspend fun getValidToken(): SSOToken? { + val token = currentToken ?: return null + if (!token.isExpired()) return token + + logout() + return null + } + + override suspend fun logout() { + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + override fun isLoggedIn(): Boolean = currentToken != null + + override fun currentAccount(): String? = currentToken?.accountLabel + + suspend fun loginWithCredentials( + apiServerUrl: String, + username: String, + password: String + ): SSOToken { + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + try { + notifyChanged() + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl = apiServerUrl, + redirectUri = URI("$apiServerUrl/oauth/token/implicit") + ) + + val token = authFlow.login( + mapOf( + "username" to username, + "password" to password + ) + ) + + currentToken = token + return token + } catch (e: Exception) { + throw SsoLoginException.Failed(e.message ?: "OpenShift credential login failed") + } finally { + loginInProgress.set(false) + notifyChanged() + } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt new file mode 100644 index 00000000..c00aefee --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + +package com.redhat.devtools.gateway.auth.session + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.components.Service +import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.RedHatAuthCodeFlow +import com.redhat.devtools.gateway.auth.code.SSOToken +import com.redhat.devtools.gateway.auth.code.SecureTokenStorage +import com.redhat.devtools.gateway.auth.config.AuthConfig +import com.redhat.devtools.gateway.auth.config.AuthType +import com.redhat.devtools.gateway.auth.oidc.OidcProviderMetadataResolver +import com.redhat.devtools.gateway.auth.server.CallbackServer +import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer +import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder +import com.redhat.devtools.gateway.auth.server.ServerConfigProvider +import kotlinx.coroutines.* +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean + +const val LOGIN_TIMEOUT_MS = 2 * 60_000L + +@Service(Service.Level.APP) +class RedHatAuthSessionManager : AuthSessionManager { + + private val tokenStorage: SecureTokenStorage = + JBPasswordSafeTokenStorage() + + private val serverConfig = runBlocking { + ServerConfigProvider.getServerConfig(AuthType.SSO_REDHAT) + } + + private val callbackServer: CallbackServer = OAuthCallbackServer(serverConfig) + + private val authConfig = AuthConfig() + + private val providerMetadata = runBlocking { + OidcProviderMetadataResolver(authConfig.authUrl).resolve() + } + + private lateinit var authFlow: RedHatAuthCodeFlow + + private val listeners = mutableSetOf() + private var currentToken: SSOToken? = null + + private val loginInProgress = AtomicBoolean(false) + + fun isLoginInProgress(): Boolean = loginInProgress.get() + + fun addListener(listener: AuthSessionListener) { + listeners += listener + } + + fun removeListener(listener: AuthSessionListener) { + listeners -= listener + } + + private fun notifyChanged() { + listeners.forEach { it.sessionChanged() } + } + + /** + * Called once on plugin startup. + */ + override suspend fun initialize() { + notifyChanged() + } + + private var pendingLogin: CompletableDeferred? = null + + suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { + val deferred = pendingLogin + ?: throw IllegalStateException("Login was not started") + + return try { + withTimeout(timeoutMs) { + deferred.await() + } + } catch (_: TimeoutCancellationException) { + throw SsoLoginException.Timeout + } + } + + /** + * Starts the login process and returns browser URL. + */ + override suspend fun startLogin(apiServerUrl: String?): URI { + if (!loginInProgress.compareAndSet(false, true)) { + throw IllegalStateException("Login already in progress") + } + + pendingLogin = CompletableDeferred() + + try { + notifyChanged() + + callbackServer.stop() + val port = callbackServer.start() + + authFlow = RedHatAuthCodeFlow( + clientId = authConfig.clientId, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + providerMetadata = providerMetadata + ) + + val request = authFlow.startAuthFlow() + CoroutineScope(Dispatchers.IO).launch { + try { + val params = callbackServer.awaitCallback(LOGIN_TIMEOUT_MS) + if (params == null) { + pendingLogin?.completeExceptionally( + SsoLoginException.Timeout + ) + notifyLoginCancelled() + + return@launch + } + + val token = authFlow.handleCallback(params) + currentToken = token + + pendingLogin?.complete(token) + } catch (e: Exception) { + pendingLogin?.completeExceptionally( + SsoLoginException.Failed(e.message ?: "SSO login failed") + ) + } finally { + pendingLogin = null + cancelLogin() + } + } + + return request.authorizationUri + } catch (e: Exception) { + pendingLogin?.completeExceptionally(e) + pendingLogin = null + cancelLogin() + throw e + } + } + + private suspend fun cancelLogin() { + loginInProgress.set(false) + notifyChanged() + callbackServer.stop() + } + + private fun notifyLoginCancelled() { + Notifications.Bus.notify( + Notification( + "RedHat Authentication", + "Login cancelled", + "You closed the browser or the login timed out.", + NotificationType.INFORMATION + ) + ) + } + + /** + * Returns a valid (non-expired) token or null. + * Refreshes automatically if possible. + */ + override suspend fun getValidToken(): SSOToken? { + val token = currentToken ?: return null + + if (!token.isExpired()) { + return token + } + + logout() + return null + } + + override suspend fun logout() { + currentToken = null + tokenStorage.clearToken() + notifyChanged() + } + + override fun isLoggedIn(): Boolean = currentToken != null + + override fun currentAccount(): String? = currentToken?.accountLabel +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt new file mode 100644 index 00000000..1f03d131 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.session + +import kotlin.Exception + +sealed class SsoLoginException : Exception() { + object Timeout : SsoLoginException() + data class Failed(val reason: String) : SsoLoginException() +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt index c84c89bd..14856a0d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Projects.kt @@ -15,6 +15,12 @@ import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.apis.AuthorizationV1Api +import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReview +import io.kubernetes.client.openapi.models.V1SelfSubjectAccessReviewSpec +import io.kubernetes.client.openapi.models.V1ResourceAttributes + class Projects(private val client: ApiClient) { @Throws(ApiException::class) fun list(): List<*> { @@ -35,4 +41,27 @@ class Projects(private val client: ApiClient) { return true } + /** + * Check if the token is valid and usable for the namespace. + * Works for user OAuth tokens and pipeline SA tokens. + */ + @Throws(ApiException::class) + fun isAuthenticatedAlternative(): Boolean { + val api = AuthorizationV1Api(client) + + val review = V1SelfSubjectAccessReview().apply { + spec = V1SelfSubjectAccessReviewSpec().apply { + resourceAttributes = V1ResourceAttributes().apply { + verb = "get" + resource = "namespaces" + } + } + } + + val response = api + .createSelfSubjectAccessReview(review) + .execute() + + return response.status?.allowed == true + } } \ No newline at end of file diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 12125449..842dddc1 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -11,37 +11,56 @@ */ package com.redhat.devtools.gateway.view.steps +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.invokeLater import com.intellij.openapi.components.service import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTabbedPane import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext +import com.redhat.devtools.gateway.auth.code.AuthTokenKind +import com.redhat.devtools.gateway.auth.code.TokenModel +import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider +import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager +import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.kubeconfig.FileWatcher import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.kubeconfig.KubeConfigUpdate +import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils import com.redhat.devtools.gateway.openshift.Cluster import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.settings.DevSpacesSettings -import com.redhat.devtools.gateway.util.message -import com.redhat.devtools.gateway.view.ui.* +import com.redhat.devtools.gateway.view.ui.Dialogs +import com.redhat.devtools.gateway.view.ui.FilteringComboBox +import com.redhat.devtools.gateway.view.ui.PasteClipboardMenu +import com.redhat.devtools.gateway.view.ui.requestInitialFocus +import io.kubernetes.client.openapi.ApiClient import kotlinx.coroutines.* import java.awt.event.ItemEvent import java.awt.event.KeyAdapter import java.awt.event.KeyEvent +import javax.swing.JComponent import javax.swing.JTextField import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener +private const val SAVE_REQUIRES_TOKEN_DIFF = + "devspaces.save.requires.token.diff" + class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, private val enableNextButton: (() -> Unit)?, @@ -55,14 +74,71 @@ class DevSpacesServerStepView( private lateinit var kubeconfigScope: CoroutineScope private lateinit var kubeconfigMonitor: KubeConfigMonitor - private val updateKubeconfigCheckbox = JBCheckBox("Save configuration") + private var saveToKubeconfig: Boolean = false + private val saveKubeconfigCheckboxes = mutableListOf() + + private fun syncSaveKubeconfigCheckboxes(source: JBCheckBox) { + saveKubeconfigCheckboxes + .filter { it !== source } + .forEach { it.isSelected = saveToKubeconfig } + } + + private fun createSaveKubeconfigCheckbox( + requiresTokenDiff: Boolean? = false + ): JBCheckBox = + JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")).apply { + isOpaque = false + background = null + + isSelected = saveToKubeconfig + + putClientProperty(SAVE_REQUIRES_TOKEN_DIFF, requiresTokenDiff) + + addActionListener { + saveToKubeconfig = isSelected + syncSaveKubeconfigCheckboxes(this) + } + + saveKubeconfigCheckboxes += this + } + + private val updateKubeconfigCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration")) + - private var tfToken = JBTextField() + private val sessionManager = + ApplicationManager.getApplication() + .getService(RedHatAuthSessionManager::class.java) + + private var tfToken = JBPasswordField() .apply { - document.addDocumentListener(onTokenChanged()) + document.addDocumentListener(onFieldChanged()) PasteClipboardMenu.addTo(this) addKeyListener(createEnterKeyListener()) } + + 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 showTokenCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_token")) + .apply { + isOpaque = false + background = null + } + private val showPasswordCheckbox = JBCheckBox(DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.show_password")) + .apply { + isOpaque = false + background = null + } + private var tfServer = FilteringComboBox.create( { it?.toString() ?: "" }, @@ -74,25 +150,134 @@ class DevSpacesServerStepView( (this.editor.editorComponent as JTextField).addKeyListener(createEnterKeyListener()) } - override val component = panel { + private enum class AuthMethod { + TOKEN, + OPENSHIFT, // browser PKCE + OPENSHIFT_CREDENTIALS, // username/password + REDHAT_SSO // RH SSO (Sandbox) + } + + private var authMethod: AuthMethod = AuthMethod.TOKEN + + + private fun updateAuthUiState() { + enableNextButton?.invoke() + } + + private fun getCurrentAuthTokenValue(): CharArray? = + when (authMethod) { + AuthMethod.TOKEN -> tfToken.password + else -> null // other tabs don't have a token yet + } + + private fun tokenPanel() = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.token")) { + cell(tfToken).align(Align.FILL) + } row { - label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.title")).applyToComponent { - font = JBFont.h2().asBold() + cell(showTokenCheckbox) + } + row { + cell(createSaveKubeconfigCheckbox(true).also { saveKubeconfigCheckboxes += it }) + } + } + + private fun openShiftOAuthPanel() = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.openshift_oauth_info")) + } + row { + cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) + } + } + + private fun credentialsPanel() = panel { + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.username")) { + cell(tfUsername).align(Align.FILL) + } + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.password")) { + cell(tfPassword).align(Align.FILL) + } + row { + cell(showPasswordCheckbox) + } + row { + cell(createSaveKubeconfigCheckbox().also { saveKubeconfigCheckboxes += it }) + } + } + + private fun redHatSSOPanel() = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_info")) + } + row { + label( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.redhat_sso_token_note") + ).comment( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.text.pipeline_token_comment") + ) + } + } + + private fun tabPanel(p: JComponent): JComponent = + p.apply { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + } + + private val authTabs = JBTabbedPane().apply { + isOpaque = false + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.token"), + tabPanel(tokenPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.openshift_oauth"), + tabPanel(openShiftOAuthPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.credentials"), + tabPanel(credentialsPanel())) + addTab( + DevSpacesBundle.message("connector.wizard_step.openshift_connection.tab.redhat_sso"), + tabPanel(redHatSSOPanel())) + + addChangeListener { + authMethod = when (selectedIndex) { + 0 -> AuthMethod.TOKEN + 1 -> AuthMethod.OPENSHIFT + 2 -> AuthMethod.OPENSHIFT_CREDENTIALS + else -> AuthMethod.REDHAT_SSO } + + updateKubeconfigCheckbox.isVisible = + authMethod != 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) + row(DevSpacesBundle.message("connector.wizard_step.openshift_connection.label.authentication")) { + cell(authTabs).align(Align.FILL) } - 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) @@ -105,6 +290,15 @@ class DevSpacesServerStepView( override fun onInit() { startKubeconfigMonitor() + updateAuthUiState() + + showTokenCheckbox.addActionListener { + tfToken.echoChar = if (showTokenCheckbox.isSelected) 0.toChar() else '•' + } + + showPasswordCheckbox.addActionListener { + tfPassword.echoChar = if (showPasswordCheckbox.isSelected) 0.toChar() else '•' + } } private fun onClusterSelected(event: ItemEvent) { @@ -116,34 +310,47 @@ class DevSpacesServerStepView( } } } - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } - private fun onTokenChanged(): DocumentListener = object : DocumentListener { + private fun onFieldChanged(): DocumentListener = object : DocumentListener { override fun insertUpdate(event: DocumentEvent) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } override fun removeUpdate(e: DocumentEvent) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } override fun changedUpdate(e: DocumentEvent?) { enableNextButton?.invoke() - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } } - private fun enableKubeconfigCheckbox() { - val cluster = tfServer.selectedItem as Cluster? - val token = tfToken.text - updateKubeconfigCheckbox.isEnabled = - !allClusters.contains(cluster) - || (cluster?.token ?: "") != token + private fun updateSaveKubeconfigCheckboxEnablement() { + val cluster = tfServer.selectedItem as? Cluster + val currentToken = getCurrentAuthTokenValue() + + val tokenChanged = + !cluster?.token.isNullOrBlank() + && currentToken?.isNotEmpty() == true + && !cluster.token.toCharArray().contentEquals(currentToken) + + saveKubeconfigCheckboxes.forEach { checkbox -> + val requiresTokenDiff = + checkbox.getClientProperty(SAVE_REQUIRES_TOKEN_DIFF) as? Boolean ?: false + + checkbox.isEnabled = + !allClusters.contains(cluster) + || !requiresTokenDiff + || tokenChanged + } } + private fun createEnterKeyListener(): KeyAdapter { return object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { @@ -165,7 +372,7 @@ class DevSpacesServerStepView( (previouslySelected)?.name ?: kubeConfigCurrentCluster, updatedClusters ) - enableKubeconfigCheckbox() + updateSaveKubeconfigCheckboxEnablement() } } } @@ -178,61 +385,190 @@ class DevSpacesServerStepView( override fun onNext(): Boolean { val selectedCluster = tfServer.selectedItem as? Cluster ?: return false val server = selectedCluster.url - val token = tfToken.text - val client = OpenShiftClientFactory(KubeConfigUtils).create(server, token.toCharArray()) var success = false stopKubeconfigMonitor() ProgressManager.getInstance().runProcessWithProgressSynchronously( { + val indicator = ProgressManager.getInstance().progressIndicator + try { - val indicator = ProgressManager.getInstance().progressIndicator - saveKubeconfig(tfServer.selectedItem as? Cluster?, tfToken.text, indicator) - indicator.text = "Checking connection..." - Projects(client).isAuthenticated() + indicator.text = "Connecting to cluster..." + + when (authMethod) { + AuthMethod.TOKEN -> { + indicator.text = "Validating token..." + + val token = String(tfToken.password) + + val client = createValidatedApiClient(server, token, + "Authentication failed: invalid server URL or token.") + + saveKubeconfig(selectedCluster, token, indicator) + devSpacesContext.client = client + } + + AuthMethod.OPENSHIFT_CREDENTIALS -> { + indicator.text = "Authenticating with OpenShift credentials..." + + val username = tfUsername.text + val password = String(tfPassword.password) + + val finalToken = runBlocking { + val sessionManager = OpenShiftAuthSessionManager() + + val osToken = sessionManager.loginWithCredentials( + apiServerUrl = selectedCluster.url, + username = username, + password = password + ) + + TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient( + server, + finalToken.accessToken, + "Authentication failed: invalid OpenShift credentials." + ) + + tfToken.text = finalToken.accessToken + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + + AuthMethod.OPENSHIFT -> { + indicator.text = "Authenticating with Openshift..." + + val finalToken = runBlocking { + val openshiftSSessionManager = OpenShiftAuthSessionManager() + val uri = openshiftSSessionManager.startLogin(selectedCluster.url) + BrowserUtil.browse(uri) + + indicator.text = "Waiting for you to complete login in your browser..." + indicator.checkCanceled() + + indicator.text = "Obtaining OpenShift access..." + val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + + TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient(server, finalToken.accessToken, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired.") + + tfToken.text = finalToken.accessToken + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } + + AuthMethod.REDHAT_SSO -> { + indicator.text = "Authenticating with Red Hat..." + + val finalToken = runBlocking { + val uri = sessionManager.startLogin() + BrowserUtil.browse(uri) + + indicator.text = "Waiting for you to complete login in your browser..." + indicator.checkCanceled() + + val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + indicator.text = "Obtaining OpenShift access..." + + val sandboxAuth = SandboxClusterAuthProvider() + sandboxAuth.authenticate(ssoToken) + } + + indicator.text = "Validating cluster access..." + + val client = createValidatedApiClient(server, finalToken.accessToken, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster.") + + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } + devSpacesContext.client = client + } + } + success = true } catch (e: Exception) { - Dialogs.error(e.message(), "Connection failed") + Dialogs.error( + e.message ?: "Unable to connect to the cluster", + "Connection Failed" + ) throw e } }, - "Checking Connection...", + "Connecting to OpenShift...", true, null ) if (success) { - settings.save(tfServer.selectedItem as? Cluster) - devSpacesContext.client = client + settings.save(selectedCluster) } return success } - override fun isNextEnabled(): Boolean { - return tfServer.selectedItem != null - && tfToken.text.isNotEmpty() - } + @Throws(IllegalArgumentException::class) + private fun createValidatedApiClient( + server: String, + token: String, + errorMessage: String? = null + ): ApiClient = OpenShiftClientFactory(KubeConfigUtils) + .create(server, token.toCharArray()) + .also { client -> + require(Projects(client).isAuthenticated()) { errorMessage ?: "Not authenticated" } + } - private fun saveKubeconfig(cluster: Cluster?, token: String?, indicator: ProgressIndicator) { - if (cluster == null - || token.isNullOrBlank() - || !updateKubeconfigCheckbox.isSelected) { - return - } + override fun isNextEnabled(): Boolean = + when (authMethod) { + AuthMethod.TOKEN -> + tfToken.password?.isNotEmpty() == true - try { - indicator.text = "Updating Kube config..." - KubeConfigUpdate - .create( - cluster.name.trim(), - cluster.url.trim(), - token.trim()) - .apply() - } catch (e: Exception) { - Dialogs.error( e.message ?: "Could not update kube config file", "Kubeconfig Update Failed") - } + AuthMethod.OPENSHIFT_CREDENTIALS -> + tfUsername.text.isNotBlank() && + tfPassword.password?.isNotEmpty() == true + + AuthMethod.OPENSHIFT, + AuthMethod.REDHAT_SSO -> + tfServer.selectedItem != null + } + + 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) { + Dialogs.error( e.message ?: "Could not update kube config file", "Kubeconfig Update Failed") + } } private fun setClusters(clusters: List) { diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 9106ff8c..3c0cbcca 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -5,7 +5,21 @@ 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.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.username=Username: +connector.wizard_step.openshift_connection.label.password=Password: +connector.wizard_step.openshift_connection.checkbox.save_configuration=Save configuration +connector.wizard_step.openshift_connection.checkbox.show_token=Show token +connector.wizard_step.openshift_connection.checkbox.show_password=Show password +connector.wizard_step.openshift_connection.text.openshift_oauth_info=Authenticate using OpenShift OAuth (browser login) +connector.wizard_step.openshift_connection.text.redhat_sso_info=Authenticate using Red Hat SSO (Sandbox only) +connector.wizard_step.openshift_connection.text.redhat_sso_token_note=Token will not be saved to kubeconfig +connector.wizard_step.openshift_connection.text.pipeline_token_comment=Pipeline tokens require special handling connector.wizard_step.openshift_connection.button.previous=Back connector.wizard_step.openshift_connection.button.next=Check connection and continue connector.wizard_step.openshift_connection.label.update_kubeconfig_path=Update kubeconfig: {0}