Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -71,6 +72,17 @@ dependencies {
implementation("io.kubernetes:client-java:25.0.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.21.1")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.1")

// RedHat Auth dependencies
implementation("io.ktor:ktor-server-core-jvm:2.3.7")
implementation("io.ktor:ktor-server-netty-jvm:2.3.7")
implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.7")

implementation("com.nimbusds:oauth2-oidc-sdk:11.15") // Core OIDC/OAuth2
implementation("com.nimbusds:nimbus-jose-jwt:9.37") // JWT processing

// JSON serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}

// Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html
Expand Down
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -20,19 +20,16 @@ import com.intellij.ui.dsl.builder.panel
import com.jetbrains.gateway.api.ConnectionRequestor
import com.jetbrains.gateway.api.GatewayConnectionHandle
import com.jetbrains.gateway.api.GatewayConnectionProvider
import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils
import com.redhat.devtools.gateway.openshift.DevWorkspaces
import com.redhat.devtools.gateway.openshift.OpenShiftClientFactory
import com.redhat.devtools.gateway.openshift.isNotFound
import com.redhat.devtools.gateway.openshift.isUnauthorized
import com.redhat.devtools.gateway.util.ProgressCountdown
import com.redhat.devtools.gateway.util.isCancellationException
import com.redhat.devtools.gateway.util.messageWithoutPrefix
import com.redhat.devtools.gateway.view.SelectClusterDialog
import com.redhat.devtools.gateway.view.ui.Dialogs
import io.kubernetes.client.openapi.ApiException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.*
import java.util.concurrent.CancellationException
import javax.swing.JComponent
import javax.swing.Timer
Expand All @@ -48,14 +45,22 @@ private const val DW_NAME = "dwName"
*/
class DevSpacesConnectionProvider : GatewayConnectionProvider {

private var clientFactory: OpenShiftClientFactory? = null

@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UnstableApiUsage")
override suspend fun connect(
parameters: Map<String, String>,
requestor: ConnectionRequestor
): GatewayConnectionHandle? {
val ctx = DevSpacesContext()

val confirmed = withContext(Dispatchers.Main) {
SelectClusterDialog(ctx).showAndConnect()
}

if (!confirmed) {
return null
}

return suspendCancellableCoroutine { cont ->
ProgressManager.getInstance().runProcessWithProgressSynchronously(
{
Expand All @@ -64,7 +69,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
indicator.isIndeterminate = true
indicator.text = "Connecting to DevSpace..."

val handle = doConnect(parameters, indicator)
val handle = doConnect(ctx, parameters, indicator)
val thinClient = handle.clientHandle
?: throw RuntimeException("Failed to obtain ThinClientHandle")

Expand Down Expand Up @@ -172,6 +177,7 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
@Suppress("UnstableApiUsage")
@Throws(IllegalArgumentException::class)
private fun doConnect(
ctx: DevSpacesContext,
parameters: Map<String, String>,
indicator: ProgressCountdown
): GatewayConnectionHandle {
Expand All @@ -191,13 +197,6 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
throw IllegalArgumentException("Query parameter \"$DW_NAME\" is missing")
}

val ctx = DevSpacesContext()

indicator.update(message = "Initializing Kubernetes connection…")
val factory = OpenShiftClientFactory(KubeConfigUtils)
this.clientFactory = factory
ctx.client = factory.create()

indicator.update(message = "Fetching DevWorkspace “$dwName” from namespace “$dwNamespace”…")
ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName)

Expand Down Expand Up @@ -245,12 +244,10 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
private fun handleUnauthorizedError(err: ApiException): Boolean {
if (!err.isUnauthorized()) return false

val tokenNote = if (clientFactory?.isTokenAuth() == true)
"\n\nYou are using token-based authentication.\nUpdate your token in the kubeconfig file."
else ""

Dialogs.error(
"Your session has expired.\nPlease log in again to continue.$tokenNote",
"Your session has expired.\n" +
"Please authenticate again to continue.\n\n" +
"If you are using token-based authentication, update your token in the kubeconfig file.",
"Authentication Required"
)
return true
Expand All @@ -275,5 +272,4 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider {
runnable.invoke()
}.start()
}

}
Original file line number Diff line number Diff line change
@@ -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<String, String>

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
}
Original file line number Diff line number Diff line change
@@ -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<TokenModel>(raw)
}.getOrNull()
}

override suspend fun clearToken() {
PasswordSafe.instance.set(attributes, null)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

Loading
Loading