diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt index 1ea0e8c9..00c25d01 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AbstractAuthSessionManager.kt @@ -13,40 +13,29 @@ package com.redhat.devtools.gateway.auth.session import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.code.JBPasswordSafeTokenStorage +import com.redhat.devtools.gateway.auth.code.Parameters import com.redhat.devtools.gateway.auth.code.SSOToken import com.redhat.devtools.gateway.auth.code.SecureTokenStorage import com.redhat.devtools.gateway.auth.server.CallbackServer -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout -import java.util.concurrent.atomic.AtomicBoolean +import java.net.URI import kotlin.time.Duration.Companion.milliseconds /** * Abstract base class for authentication session managers. * - * Provides common functionality for OAuth-based authentication including: - * - Token storage and retrieval with expiration handling - * - Login state management with concurrent access protection - * - Session listener notifications - * - Callback server lifecycle management - * - * ## Thread Safety - * This class is designed for concurrent access: - * - Login state is protected by [AtomicBoolean] - * - Token access is protected by [Mutex] - * - Listener notifications are fail-safe and copy-on-iterate - * - * ## Subclass Responsibilities - * Implementations must provide: - * - [initialize]: One-time setup on plugin startup - * - [startLogin]: Provider-specific OAuth flow initialization - * - [loginWithCredentials]: Provider-specific credential-based login (or throw UnsupportedOperationException) - * - [callbackServer]: The callback server instance for OAuth flows - * - * @param tokenStorage Storage mechanism for persisting tokens securely. Defaults to [JBPasswordSafeTokenStorage] + * Browser logins use a single [BrowserLogin] at a time. + * Starting a new login or calling [BrowserLogin.cancel] ends any previous in-flight login. */ abstract class AbstractAuthSessionManager( protected val tokenStorage: SecureTokenStorage = JBPasswordSafeTokenStorage() @@ -54,100 +43,152 @@ abstract class AbstractAuthSessionManager( protected abstract val callbackServer: CallbackServer - private val listeners = mutableSetOf() + companion object { + const val LOGIN_TIMEOUT_MS = 2 * 60_000L + } + private val tokenMutex = Mutex() - private var _currentToken: SSOToken? = null + private val loginMutex = Mutex() + private val loginScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var _currentToken: SSOToken? = null protected var currentToken: SSOToken? get() = _currentToken set(value) { _currentToken = value } - protected val loginInProgress = AtomicBoolean(false) - protected var pendingLogin: CompletableDeferred? = null + private var activeLogin: BrowserLogin? = null /** - * Checks if a login operation is currently in progress. - * - * @return true if login is in progress, false otherwise + * Creates a [BrowserLogin] and registers it as active. Caller must run inside [withBrowserLoginLock]. */ - fun isLoginInProgress(): Boolean = loginInProgress.get() + protected fun createBrowserLogin(authorizationUri: URI): BrowserLogin { + val login = BrowserLogin.create(this, authorizationUri) + activeLogin = login + return login + } /** - * Adds a session change listener. - * Thread-safe and can be called during listener notification. - * - * @param listener The listener to add + * Stops the current browser login, if any. Caller must run inside [withBrowserLoginLock]. */ - fun addListener(listener: AuthSessionListener) { - synchronized(listeners) { - listeners += listener + protected suspend fun endActiveLogin() { + val previous = activeLogin ?: return + thisLogger().debug("Ending in-flight browser login") + previous.job?.cancelAndJoin() + previous.result.takeIf { !it.isCompleted }?.completeExceptionally(SsoLoginException.Cancelled()) + callbackServer.stop() + activeLogin = null + } + + internal suspend fun awaitBrowserLoginResult(login: BrowserLogin, timeoutMs: Long): SSOToken { + check(activeLogin === login) { "Login is no longer active" } + thisLogger().debug("Awaiting browser login result with timeout ${timeoutMs}ms") + return try { + val token = withTimeout(timeoutMs.milliseconds) { + login.result.await() + } + thisLogger().info("Login result received successfully") + token + } catch (e: TimeoutCancellationException) { + thisLogger().warn("Login timed out after ${timeoutMs}ms") + cancelBrowserLogin(login) + throw SsoLoginException.Timeout() } } - /** - * Removes a session change listener. - * Thread-safe and can be called during listener notification. - * - * @param listener The listener to remove - */ - fun removeListener(listener: AuthSessionListener) { - synchronized(listeners) { - listeners -= listener + internal suspend fun cancelBrowserLogin(login: BrowserLogin) = withBrowserLoginLock { + if (activeLogin !== login) { + thisLogger().debug("Ignoring cancel for stale browser login") + return@withBrowserLoginLock } + thisLogger().debug("Cancelling browser login") + login.job?.cancelAndJoin() + login.result.takeIf { !it.isCompleted }?.completeExceptionally(SsoLoginException.Cancelled()) + callbackServer.stop() + activeLogin = null } - /** - * Notifies all registered listeners of a session change. - * Notification failures are logged but do not prevent other listeners from being notified. - */ - protected fun notifyChanged() { - val listenersCopy = synchronized(listeners) { listeners.toList() } - listenersCopy.forEach { listener -> + protected fun launchCallbackHandler( + login: BrowserLogin, + callbackTimeoutMs: Long, + onCallbackTimeout: () -> Unit, + handleCallback: suspend (Parameters) -> SSOToken, + ) { + login.job = loginScope.launch { try { - listener.sessionChanged() + thisLogger().debug("Waiting for OAuth callback...") + val params = callbackServer.awaitCallback(callbackTimeoutMs) + if (params == null) { + if (!login.result.isCompleted) { + val error = if (isActive) { + SsoLoginException.Timeout() + } else { + SsoLoginException.Cancelled() + } + login.result.completeExceptionally(error) + } + if (isActive) { + thisLogger().warn("OAuth callback timed out") + onCallbackTimeout() + } + return@launch + } + + thisLogger().debug("OAuth callback received, handling...") + val token = handleCallback(params) + currentToken = token + completeLoginSuccess(login, token) + } catch (e: CancellationException) { + completeLoginCancelled(login) + throw e } catch (e: Exception) { - thisLogger().error("Session listener notification failed", e) + thisLogger().error("Browser login failed", e) + completeLoginFailed(login, e) + } finally { + withBrowserLoginLock { releaseActiveLogin(login) } } } } - /** - * Awaits the result of a login operation started via [startLogin]. - * - * @param timeoutMs Maximum time to wait in milliseconds - * @return The received SSO token - * @throws IllegalStateException if login was not started - * @throws SsoLoginException.Timeout if the operation times out - */ - override suspend fun awaitLoginResult(timeoutMs: Long): SSOToken { - val deferred = pendingLogin ?: throw IllegalStateException("Login was not started") - thisLogger().debug("Awaiting login result with timeout ${timeoutMs}ms") - return try { - val token = withTimeout(timeoutMs.milliseconds) { deferred.await() } - thisLogger().info("Login result received successfully") - token - } catch (e: TimeoutCancellationException) { - thisLogger().warn("Login timed out after ${timeoutMs}ms") - throw SsoLoginException.Timeout() + protected fun completeLoginSuccess(login: BrowserLogin, token: SSOToken) { + if (!login.result.isCompleted) { + login.result.complete(token) + } + } + + protected fun completeLoginFailed(login: BrowserLogin, e: Exception) { + if (login.result.isCompleted) return + val error = when (e) { + is SsoLoginException -> e + else -> SsoLoginException.Failed(e.message ?: "Login failed") + } + login.result.completeExceptionally(error) + } + + protected fun completeLoginCancelled(login: BrowserLogin) { + if (!login.result.isCompleted) { + login.result.completeExceptionally(SsoLoginException.Cancelled()) } } /** - * Cancels the current login operation and cleans up resources. + * Stops the callback server and clears [activeLogin] when [login] is still current. + * Caller must run inside [withBrowserLoginLock]. */ - protected suspend fun cancelLogin() { - thisLogger().debug("Cancelling login") - loginInProgress.set(false) - notifyChanged() + protected suspend fun releaseActiveLogin(login: BrowserLogin) { + if (activeLogin !== login) return + thisLogger().debug("Releasing browser login") callbackServer.stop() + activeLogin = null } - /** - * Returns a valid (non-expired) token or null. - * If the current token is expired, automatically logs out and returns null. - * - * @return A valid token or null if not logged in or token is expired - */ + protected suspend fun failBrowserLoginStart(login: BrowserLogin, e: Exception) { + completeLoginFailed(login, e) + releaseActiveLogin(login) + } + + protected suspend fun withBrowserLoginLock(block: suspend () -> T): T = + loginMutex.withLock { block() } + override suspend fun getValidToken(): SSOToken? = tokenMutex.withLock { val token = currentToken if (token == null) { @@ -161,42 +202,23 @@ abstract class AbstractAuthSessionManager( } thisLogger().info("Token expired for account: ${token.accountLabel}, logging out") - // Call private logout method to avoid deadlock (already holding tokenMutex) doLogout() return null } - /** - * Logs out the current user by clearing the token from storage and memory. - * Notifies all registered listeners of the session change. - */ override suspend fun logout() = tokenMutex.withLock { doLogout() } - /** - * Internal logout implementation that doesn't acquire the lock. - * Must be called while holding tokenMutex. - */ private suspend fun doLogout() { val account = currentToken?.accountLabel thisLogger().info("Logging out${if (account != null) " account: $account" else ""}") + withBrowserLoginLock { endActiveLogin() } currentToken = null tokenStorage.clearToken() - notifyChanged() } - /** - * Checks if a user is currently logged in. - * - * @return true if a token exists (regardless of expiration), false otherwise - */ override fun isLoggedIn(): Boolean = currentToken != null - /** - * Returns the account label of the current token. - * - * @return The account label or null if not logged in - */ override fun currentAccount(): String? = currentToken?.accountLabel } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt index 81ac7473..a0c463b6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionManager.kt @@ -12,19 +12,15 @@ package com.redhat.devtools.gateway.auth.session import com.redhat.devtools.gateway.auth.code.SSOToken -import java.net.URI import javax.net.ssl.SSLContext interface AuthSessionManager { - /** Called once on plugin startup to load any existing token. */ - suspend fun initialize() - - /** Starts login and returns browser URL */ - suspend fun startLogin(apiServerUrl: String? = null, sslContext: SSLContext): URI - - /** Awaits for the browser login result */ - suspend fun awaitLoginResult(timeoutMs: Long): SSOToken + /** + * Starts a browser OAuth login and returns a handle to open the authorization URL + * and await the result. + */ + suspend fun startBrowserLogin(apiServerUrl: String? = null, sslContext: SSLContext): BrowserLogin /** Starts login using the given credentials and returns a valid token */ suspend fun loginWithCredentials(apiServerUrl: String, username: String, password: String, sslContext: SSLContext): SSOToken @@ -40,4 +36,4 @@ interface AuthSessionManager { /** 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/BrowserLogin.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/BrowserLogin.kt new file mode 100644 index 00000000..eb43bf42 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/BrowserLogin.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.session + +import com.redhat.devtools.gateway.auth.code.SSOToken +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import java.net.URI + +/** + * In-flight browser OAuth login started via [AuthSessionManager.startBrowserLogin]. + */ +class BrowserLogin private constructor( + private val manager: AbstractAuthSessionManager, + val authorizationUri: URI, +) { + + internal val result = CompletableDeferred() + internal var job: Job? = null + + suspend fun awaitResult(timeoutMs: Long): SSOToken = + manager.awaitBrowserLoginResult(this, timeoutMs) + + suspend fun cancel() { + manager.cancelBrowserLogin(this) + } + + internal companion object { + fun create(manager: AbstractAuthSessionManager, authorizationUri: URI): BrowserLogin = + BrowserLogin(manager, authorizationUri) + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt index d3ca0ebc..a79f0ce5 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/OpenShiftAuthSessionManager.kt @@ -17,19 +17,16 @@ import com.intellij.notification.Notifications import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.auth.code.OpenShiftAuthCodeFlow -import com.redhat.devtools.gateway.auth.code.Parameters import com.redhat.devtools.gateway.auth.code.SSOToken import com.redhat.devtools.gateway.auth.config.AuthType import com.redhat.devtools.gateway.auth.server.CallbackServer import com.redhat.devtools.gateway.auth.server.OAuthCallbackServer import com.redhat.devtools.gateway.auth.server.RedirectUrlBuilder import com.redhat.devtools.gateway.auth.server.ServerConfigProvider -import kotlinx.coroutines.* +import kotlinx.coroutines.runBlocking import java.net.URI import javax.net.ssl.SSLContext -const val OPENSHIFT_LOGIN_TIMEOUT_MS = 2 * 60_000L - @Service(Service.Level.APP) class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { @@ -41,75 +38,46 @@ class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { private lateinit var authFlow: OpenShiftAuthCodeFlow - override suspend fun initialize() { - thisLogger().info("OpenShiftAuthSessionManager initialized") - notifyChanged() - } - - override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { + override suspend fun startBrowserLogin(apiServerUrl: String?, sslContext: SSLContext): BrowserLogin { if (apiServerUrl == null) { thisLogger().error("API Server URL is null") throw IllegalStateException("Provide API Server URL") } - if (!loginInProgress.compareAndSet(false, true)) { - thisLogger().warn("Login already in progress") - throw IllegalStateException("Login already in progress") - } - - thisLogger().info("Starting OpenShift login for API server: $apiServerUrl") - pendingLogin = CompletableDeferred() - try { - notifyChanged() - - callbackServer.stop() - val port = callbackServer.start() - thisLogger().debug("Callback server started on port: $port") - - authFlow = OpenShiftAuthCodeFlow( - apiServerUrl = apiServerUrl, - redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), - sslContext = sslContext - ) - - val request = authFlow.startAuthFlow() - thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") + return withBrowserLoginLock { + endActiveLogin() + var login: BrowserLogin? = null + try { + callbackServer.stop() + val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") + + authFlow = OpenShiftAuthCodeFlow( + apiServerUrl = apiServerUrl, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + sslContext = sslContext + ) - CoroutineScope(Dispatchers.IO).launch { - try { - thisLogger().debug("Waiting for OAuth callback...") - val params: Parameters? = callbackServer.awaitCallback(OPENSHIFT_LOGIN_TIMEOUT_MS) - if (params == null) { - thisLogger().warn("OAuth callback timed out or was cancelled") - pendingLogin?.completeExceptionally(SsoLoginException.Timeout()) - notifyLoginCancelled() - return@launch - } + val request = authFlow.startAuthFlow() + thisLogger().info("Starting OpenShift login for API server: $apiServerUrl, authorization URI: ${request.authorizationUri}") - thisLogger().debug("OAuth callback received, handling...") - val token: SSOToken = authFlow.handleCallback(params) - currentToken = token + login = createBrowserLogin(request.authorizationUri) + launchCallbackHandler( + login = login, + callbackTimeoutMs = LOGIN_TIMEOUT_MS, + onCallbackTimeout = ::notifyLoginCancelled, + ) { params -> + val token = authFlow.handleCallback(params) thisLogger().info("OpenShift login successful for account: ${token.accountLabel}") - pendingLogin?.complete(token) - - } catch (e: Exception) { - thisLogger().error("OpenShift login failed", e) - pendingLogin?.completeExceptionally( - SsoLoginException.Failed(e.message ?: "OpenShift login failed") - ) - } finally { - pendingLogin = null - cancelLogin() + token } - } - return request.authorizationUri - } catch (e: Exception) { - thisLogger().error("Failed to start OpenShift login", e) - pendingLogin?.completeExceptionally(e) - pendingLogin = null - cancelLogin() - throw e + login + } catch (e: Exception) { + thisLogger().error("Failed to start OpenShift login", e) + login?.let { failBrowserLoginStart(it, e) } + throw e + } } } @@ -130,15 +98,8 @@ class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { password: String, sslContext: SSLContext ): SSOToken { - if (!loginInProgress.compareAndSet(false, true)) { - thisLogger().warn("Login with credentials already in progress") - throw IllegalStateException("Login already in progress") - } - thisLogger().info("Starting OpenShift credential login for user: $username at $apiServerUrl") try { - notifyChanged() - authFlow = OpenShiftAuthCodeFlow( apiServerUrl = apiServerUrl, redirectUri = URI("$apiServerUrl/oauth/token/implicit"), @@ -158,9 +119,6 @@ class OpenShiftAuthSessionManager : AbstractAuthSessionManager() { } catch (e: Exception) { thisLogger().error("OpenShift credential login failed for user: $username", e) throw SsoLoginException.Failed(e.message ?: "OpenShift credential login failed") - } finally { - loginInProgress.set(false) - notifyChanged() } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index 59afabf2..16c27c65 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -27,12 +27,9 @@ 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 kotlinx.coroutines.runBlocking import javax.net.ssl.SSLContext -const val LOGIN_TIMEOUT_MS = 2 * 60_000L - @Service(Service.Level.APP) class RedHatAuthSessionManager : AbstractAuthSessionManager() { @@ -50,82 +47,42 @@ class RedHatAuthSessionManager : AbstractAuthSessionManager() { private lateinit var authFlow: RedHatAuthCodeFlow - /** - * Called once on plugin startup. - */ - override suspend fun initialize() { - thisLogger().info("RedHatAuthSessionManager initialized") - notifyChanged() - } - - /** - * Starts the login process and returns browser URL. - */ - override suspend fun startLogin(apiServerUrl: String?, sslContext: SSLContext): URI { - if (!loginInProgress.compareAndSet(false, true)) { - thisLogger().warn("Login already in progress") - throw IllegalStateException("Login already in progress") - } - - thisLogger().info("Starting Red Hat SSO login") - pendingLogin = CompletableDeferred() - - try { - notifyChanged() - - callbackServer.stop() - val port = callbackServer.start() - thisLogger().debug("Callback server started on port: $port") - - authFlow = RedHatAuthCodeFlow( - clientId = authConfig.clientId, - redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), - providerMetadata = providerMetadata - ) - - val request = authFlow.startAuthFlow() - thisLogger().debug("Auth flow started, authorization URI: ${request.authorizationUri}") - - CoroutineScope(Dispatchers.IO).launch { - try { - thisLogger().debug("Waiting for OAuth callback...") - val params = callbackServer.awaitCallback(LOGIN_TIMEOUT_MS) - if (params == null) { - thisLogger().warn("OAuth callback timed out or was cancelled") - pendingLogin?.completeExceptionally( - SsoLoginException.Timeout() - ) - notifyLoginCancelled() - - return@launch - } - - thisLogger().debug("OAuth callback received, handling...") + override suspend fun startBrowserLogin(apiServerUrl: String?, sslContext: SSLContext): BrowserLogin = + withBrowserLoginLock { + endActiveLogin() + var login: BrowserLogin? = null + try { + callbackServer.stop() + val port = callbackServer.start() + thisLogger().debug("Callback server started on port: $port") + + authFlow = RedHatAuthCodeFlow( + clientId = authConfig.clientId, + redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), + providerMetadata = providerMetadata + ) + + val request = authFlow.startAuthFlow() + thisLogger().info("Starting Red Hat SSO login, authorization URI: ${request.authorizationUri}") + + login = createBrowserLogin(request.authorizationUri) + launchCallbackHandler( + login = login, + callbackTimeoutMs = LOGIN_TIMEOUT_MS, + onCallbackTimeout = ::notifyLoginCancelled, + ) { params -> val token = authFlow.handleCallback(params) - currentToken = token thisLogger().info("Red Hat SSO login successful for account: ${token.accountLabel}") - - pendingLogin?.complete(token) - } catch (e: Exception) { - thisLogger().error("Red Hat SSO login failed", e) - pendingLogin?.completeExceptionally( - SsoLoginException.Failed(e.message ?: "SSO login failed") - ) - } finally { - pendingLogin = null - cancelLogin() + token } - } - return request.authorizationUri - } catch (e: Exception) { - thisLogger().error("Failed to start Red Hat SSO login", e) - pendingLogin?.completeExceptionally(e) - pendingLogin = null - cancelLogin() - throw e + login + } catch (e: Exception) { + thisLogger().error("Failed to start Red Hat SSO login", e) + login?.let { failBrowserLoginStart(it, e) } + throw e + } } - } override suspend fun loginWithCredentials( apiServerUrl: String, diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt index a8e5d1aa..1bda7ae1 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/SsoLoginException.kt @@ -13,7 +13,8 @@ package com.redhat.devtools.gateway.auth.session import kotlin.Exception -sealed class SsoLoginException : Exception() { - class Timeout : SsoLoginException() - data class Failed(val reason: String) : SsoLoginException() +sealed class SsoLoginException(message: String) : Exception(message) { + class Timeout : SsoLoginException("Login timed out") + class Failed(message: String) : SsoLoginException(message) + class Cancelled : SsoLoginException("Login cancelled") } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt index c36366b9..2a1152a5 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/Cluster.kt @@ -14,6 +14,7 @@ package com.redhat.devtools.gateway.openshift import com.redhat.devtools.gateway.auth.tls.CertificateSource import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toName import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils.toUriWithHost +import com.redhat.devtools.gateway.util.stripScheme data class Cluster( val name: String, @@ -85,13 +86,7 @@ data class Cluster( } val id: String - get() { - return "$name@${ - url - .removePrefix("https://") - .removePrefix("http://") - }" - } + get() = "$name@${url.stripScheme()}" override fun toString(): String { return "$name ($url)" diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt index 68095d08..cf371292 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExceptionUtils.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.util +import com.redhat.devtools.gateway.auth.session.SsoLoginException import kotlinx.coroutines.TimeoutCancellationException import java.util.concurrent.CancellationException import java.util.concurrent.TimeoutException @@ -23,3 +24,6 @@ fun Throwable.messageWithoutPrefix(): String? { fun Throwable.isTimeoutException(): Boolean = (this is TimeoutCancellationException || this is TimeoutException ) fun Throwable.isCancellationException(): Boolean = (this is CancellationException && !isTimeoutException() ) + +fun Throwable.isLoginUserCancelled(): Boolean = + generateSequence(this) { it.cause }.any { it is SsoLoginException.Cancelled } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt similarity index 66% rename from src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt rename to src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt index a1a8d339..03e4dbd1 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/AuthSessionListener.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/UrlUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Red Hat, Inc. + * Copyright (c) 2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -9,8 +9,6 @@ * Contributors: * Red Hat, Inc. - initial API and implementation */ -package com.redhat.devtools.gateway.auth.session +package com.redhat.devtools.gateway.util -interface AuthSessionListener { - fun sessionChanged() -} +fun String.stripScheme(): String = substringAfter("://", this) 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 5c6e7c6d..8397f9e3 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 @@ -43,6 +43,8 @@ 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 com.redhat.devtools.gateway.util.isLoginUserCancelled +import com.redhat.devtools.gateway.util.stripScheme import kotlinx.coroutines.* import java.awt.event.ItemEvent import java.awt.event.KeyAdapter @@ -56,7 +58,7 @@ import javax.swing.event.DocumentListener class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, private val enableNextButton: (() -> Unit)?, - private val triggerNextAction: (() -> Unit)? = null + private val triggerNextAction: (() -> Unit)? = null, ) : DevSpacesWizardStep { private lateinit var allClusters: List @@ -381,7 +383,6 @@ class DevSpacesServerStepView( override fun onNext(): Boolean { val selectedCluster = getSelectedCluster() ?: return false val server = selectedCluster.url - val serverDisplay = server.removePrefix("https://").removePrefix("http://") val strategy = currentStrategy ?: return false if (!confirmAuthSwitchIfNeeded()) return false @@ -405,8 +406,8 @@ class DevSpacesServerStepView( server, certAuthorityData, tlsContext, - indicator, - devSpacesContext + devSpacesContext, + indicator ) authResult = Result.success(Unit) } catch (e: Exception) { @@ -416,7 +417,8 @@ class DevSpacesServerStepView( }, "Connecting to OpenShift...", true, - null + null, + component ) val result = authResult!! @@ -427,10 +429,12 @@ class DevSpacesServerStepView( }, onFailure = { e -> thisLogger().warn(e) - Dialogs.error( - "Could not connect to cluster $serverDisplay.\n\nReason: ${e.message ?: "Unknown error"}", - "Connection Failed" - ) + if (!e.isLoginUserCancelled()) { + Dialogs.error( + "Could not connect to cluster ${server.stripScheme()}.\n\nReason: ${e.message ?: "Unknown error"}", + "Connection Failed" + ) + } false } ) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt index 7b164a54..48b7fa65 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AbstractAuthenticationStrategy.kt @@ -21,6 +21,13 @@ import com.redhat.devtools.gateway.openshift.Projects import com.redhat.devtools.gateway.openshift.codeToReasonPhrase import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds /** * Abstract base class for authentication strategies. @@ -40,6 +47,23 @@ abstract class AbstractAuthenticationStrategy( override fun isDirty(saved: Cluster): Boolean = false + /** + * Starts a cancellation watcher that polls the progress indicator + * and cancels the given action when the user cancels the operation. + */ + protected fun CoroutineScope.launchCancelWatcher( + indicator: ProgressIndicator, + cancelAction: suspend () -> Unit + ): Job = launch(Dispatchers.Default) { + while (isActive) { + if (indicator.isCanceled) { + cancelAction() + return@launch + } + delay(500.milliseconds) + } + } + /** * Creates a validated API client. */ diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt index 4c61b748..849f6203 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/AuthenticationStrategy.kt @@ -55,8 +55,8 @@ interface AuthenticationStrategy { server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) /** diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt index 09682f25..450589d2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/ClientCertificateAuthenticationStrategy.kt @@ -78,8 +78,8 @@ class ClientCertificateAuthenticationStrategy( server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) { indicator.text = "Validating client certificate..." diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt index ecab11b9..0228c517 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftCredentialsAuthenticationStrategy.kt @@ -106,8 +106,8 @@ class OpenShiftCredentialsAuthenticationStrategy( server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) { indicator.text = "Authenticating with OpenShift credentials..." diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt index 74d856f2..eeab88a4 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/OpenShiftOAuthAuthenticationStrategy.kt @@ -18,14 +18,11 @@ import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.code.AuthTokenKind import com.redhat.devtools.gateway.auth.code.TokenModel -import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.AbstractAuthSessionManager import com.redhat.devtools.gateway.auth.session.OpenShiftAuthSessionManager import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import javax.swing.JPanel /** @@ -56,49 +53,52 @@ class OpenShiftOAuthAuthenticationStrategy( server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) { indicator.text = "Authenticating with OpenShift..." - val openshiftSSessionManager = OpenShiftAuthSessionManager() - val uri = openshiftSSessionManager.startLogin( + val sessionManager = OpenShiftAuthSessionManager() + val login = sessionManager.startBrowserLogin( selectedCluster.url, tlsContext.sslContext ) withContext(Dispatchers.Main) { - BrowserUtil.browse(uri) + BrowserUtil.browse(login.authorizationUri) } indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() - indicator.text = "Obtaining OpenShift access..." - val osToken = openshiftSSessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) + coroutineScope { + launchCancelWatcher(indicator) { login.cancel() } - val finalToken = TokenModel( - accessToken = osToken.accessToken, - expiresAt = osToken.expiresAt, - accountLabel = osToken.accountLabel, - kind = AuthTokenKind.TOKEN, - clusterApiUrl = selectedCluster.url - ) + indicator.text = "Obtaining OpenShift access..." + val osToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) - indicator.text = "Validating cluster access..." + val finalToken = TokenModel( + accessToken = osToken.accessToken, + expiresAt = osToken.expiresAt, + accountLabel = osToken.accountLabel, + kind = AuthTokenKind.TOKEN, + clusterApiUrl = selectedCluster.url + ) - val client = createValidatedApiClient( - server, - certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: token received from OpenShift Authenticator is invalid or expired." - ) + indicator.text = "Validating cluster access..." + val client = createValidatedApiClient( + server, + certAuthority, + finalToken.accessToken, + null, + null, + tlsContext, + "Authentication failed: token received from OpenShift Authenticator is invalid or expired." + ) - setTokenDisplay(finalToken.accessToken) - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) - devSpacesContext.client = client + setTokenDisplay(finalToken.accessToken) + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + devSpacesContext.client = client + } } override fun isNextEnabled(): Boolean = diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt index 2fb846ce..94a91dbf 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/RedHatSSOAuthenticationStrategy.kt @@ -18,14 +18,11 @@ import com.redhat.devtools.gateway.DevSpacesBundle import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.auth.code.AuthTokenKind import com.redhat.devtools.gateway.auth.sandbox.SandboxClusterAuthProvider -import com.redhat.devtools.gateway.auth.session.LOGIN_TIMEOUT_MS +import com.redhat.devtools.gateway.auth.session.AbstractAuthSessionManager import com.redhat.devtools.gateway.auth.session.RedHatAuthSessionManager import com.redhat.devtools.gateway.auth.tls.TlsContext import com.redhat.devtools.gateway.openshift.Cluster -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import javax.swing.JPanel /** @@ -56,44 +53,48 @@ class RedHatSSOAuthenticationStrategy( server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) { indicator.text = "Authenticating with Red Hat..." - val uri = sessionManager.startLogin(sslContext = tlsContext.sslContext) + val login = sessionManager.startBrowserLogin(sslContext = tlsContext.sslContext) withContext(Dispatchers.Main) { - BrowserUtil.browse(uri) + BrowserUtil.browse(login.authorizationUri) } indicator.text = "Waiting for you to complete login in your browser..." currentCoroutineContext().ensureActive() - val ssoToken = sessionManager.awaitLoginResult(LOGIN_TIMEOUT_MS) - indicator.text = "Obtaining OpenShift access..." - - val sandboxAuth = SandboxClusterAuthProvider() - val finalToken = sandboxAuth.authenticate(ssoToken) - - indicator.text = "Validating cluster access..." - - try { - val client = createValidatedApiClient( - server, certAuthority, - finalToken.accessToken, - null, - null, - tlsContext, - "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." - ) - - // Do not save SSO tokens - if (finalToken.kind == AuthTokenKind.PIPELINE) { - saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + coroutineScope { + launchCancelWatcher(indicator) { login.cancel() } + + val ssoToken = login.awaitResult(AbstractAuthSessionManager.LOGIN_TIMEOUT_MS) + indicator.text = "Obtaining OpenShift access..." + + val sandboxAuth = SandboxClusterAuthProvider() + val finalToken = sandboxAuth.authenticate(ssoToken) + + indicator.text = "Validating cluster access..." + + try { + val client = createValidatedApiClient( + server, certAuthority, + finalToken.accessToken, + null, + null, + tlsContext, + "Authentication failed: Red Hat SSO token is invalid or unauthorized for this cluster." + ) + + // Do not save SSO tokens + if (finalToken.kind == AuthTokenKind.PIPELINE) { + saveKubeconfig(selectedCluster, finalToken.accessToken, indicator) + } + devSpacesContext.client = client + } catch (e: AuthenticationException) { + throw AuthenticationException("${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", e) } - devSpacesContext.client = client - } catch (e: AuthenticationException) { - throw AuthenticationException("${e.message}\n\nVerify that the cluster has Red Hat SSO enabled.", e) } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt index 289035f7..68fc6980 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/auth/TokenAuthenticationStrategy.kt @@ -87,8 +87,8 @@ class TokenAuthenticationStrategy( server: String, certAuthority: String?, tlsContext: TlsContext, - indicator: ProgressIndicator, - devSpacesContext: DevSpacesContext + devSpacesContext: DevSpacesContext, + indicator: ProgressIndicator ) { indicator.text = "Validating token..."