From f0fc480f1aeb2d563129cc0ab6c26eccbf7c9561 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 29 Apr 2026 23:54:23 +0300 Subject: [PATCH 1/2] fix: OAuth2 authorization token revocation flow No error was surfaced to the user for the case where the authorization for the plugin was revoked from the dashboard by the user. In fact the plugin continued to list the workspaces giving the false impression that everything is fine and dandy. Hoewever, if there was any action like starting or stopping a workspace then an API response error was listed. The issue was mainly caused by the retry logic from the htttp client. There is a sequence that was trying to detect 401 - unauthorized responses from the server when workspaces were listed. Before proceeding with the next step of trying to refresh the authorziation token - we made a quick test to check if the token was not refreshed in the meantime by another coroutine. But the logic was looking at the wrong header for comparing the access token ("Bearer" instead of "Coder-Session-Token"). But that was only a part of the problem. The second issue was that errors related to authorization token refresh were simply logged without being reported back to the UI or to the main polling loop. And in fact the polling loop continued to run without any success and without any error reported. The fix involved a bit of code refactoring but the gist is that access token refresh errors are now reported to main polling loop, which: - stops polling the workspaces - resets the screen to the main login wizard - displays a nice error regarding token refresh failure. - resolves https://linear.app/codercom/issue/DEVEX-221 --- .../com/coder/toolbox/CoderRemoteProvider.kt | 3 +- .../oauth/ClientRegistrationResponse.kt | 2 - .../com/coder/toolbox/oauth/OAuth2Client.kt | 23 ++++----- .../com/coder/toolbox/sdk/CoderRestClient.kt | 7 +-- .../coder/toolbox/sdk/ex/OAuth2Exceptions.kt | 6 +++ .../toolbox/sdk/interceptors/Interceptors.kt | 4 +- .../kotlin/com/coder/toolbox/util/Dialogs.kt | 4 -- .../coder/toolbox/util/StateFlowExtensions.kt | 5 -- .../kotlin/com/coder/toolbox/util/Without.kt | 47 ------------------- 9 files changed, 23 insertions(+), 78 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/ex/OAuth2Exceptions.kt delete mode 100644 src/main/kotlin/com/coder/toolbox/util/Without.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 12ba7483..49de06f5 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -8,6 +8,7 @@ import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.ex.OAuthTokenResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi @@ -158,7 +159,7 @@ class CoderRemoteProvider( context.logger.info("wake-up from an OS sleep was detected") } else { context.logger.error(ex, "workspace polling error encountered") - if (ex is APIResponseException && ex.isTokenExpired) { + if (ex is APIResponseException && ex.isTokenExpired || ex is OAuthTokenResponseException) { close() context.envPageManager.showPluginEnvironmentsPage() errorBuffer.add(ex) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt index 5ecb0e2b..415dea97 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -43,5 +43,3 @@ data class ClientRegistrationErrorResponse( } } } - -class ClientRegistrationException(message: String) : Exception(message) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt index 95abc6ff..58384d02 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt @@ -3,6 +3,8 @@ package com.coder.toolbox.oauth import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.convertors.LoggingConverterFactory +import com.coder.toolbox.sdk.ex.ClientRegistrationException +import com.coder.toolbox.sdk.ex.OAuthTokenResponseException import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.squareup.moshi.Moshi import okhttp3.Credentials @@ -33,13 +35,9 @@ class OAuth2Client(private val context: CoderToolboxContext) { } val errorBody = response.errorBody()?.string() - val registrationError = errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) } - val errorMessage = if (registrationError != null) { - "OAuth2 client registration failed: ${registrationError.toMessage()}" - } else { - "OAuth2 client registration failed with status ${response.code()}: ${response.message()}" - } - context.logger.error(errorMessage) + val registrationError = + errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) }?.toMessage() ?: "${response.message()}" + val errorMessage = "OAuth2 client registration failed with status ${response.code()}: $registrationError" throw ClientRegistrationException(errorMessage) } @@ -114,14 +112,9 @@ class OAuth2Client(private val context: CoderToolboxContext) { } val errorBody = response.errorBody()?.string() - val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) } - val errorMessage = if (tokenError != null) { - "Failed to $action: ${tokenError.toMessage()}" - } else { - "Failed to $action. Response code: ${response.code()} ${response.message()}" - } - context.logger.error(errorMessage) - throw Exception(errorMessage) + val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) }?.toMessage() ?: "${response.message()}" + val errorMessage = "Failed to $action. Response code: ${response.code()} $tokenError" + throw OAuthTokenResponseException(errorMessage) } private fun createAuthorizationService(): CoderAuthorizationApi { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 8171155e..9137a5f5 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.interceptors.CODER_SESSION_TOKEN_HEADER_NAME import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse @@ -358,7 +359,7 @@ open class CoderRestClient( if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED && oauthContext.hasRefreshToken()) { val tokenRefreshed = refreshMutex.withLock { // Check if the token was already refreshed while we were waiting for the lock. - if (response.raw().request.header("Authorization") != "Bearer ${oauthContext?.tokenResponse?.accessToken}") { + if (response.raw().request.header(CODER_SESSION_TOKEN_HEADER_NAME) != oauthContext?.tokenResponse?.accessToken) { return@withLock true } return@withLock try { @@ -372,7 +373,7 @@ open class CoderRestClient( true } catch (e: Exception) { context.logger.error(e, "Failed to refresh access token") - false + throw e } } if (tokenRefreshed) { @@ -425,7 +426,7 @@ open class CoderRestClient( } } catch (ex: Exception) { context.logger.error(ex, "Failed to execute refresh command") - false + throw ex } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/OAuth2Exceptions.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/OAuth2Exceptions.kt new file mode 100644 index 00000000..e1d5bf59 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/OAuth2Exceptions.kt @@ -0,0 +1,6 @@ +package com.coder.toolbox.sdk.ex + +sealed class OAuth2ErrorException(message: String?) : Exception(message) + +class ClientRegistrationException(message: String) : OAuth2ErrorException(message) +class OAuthTokenResponseException(message: String?) : OAuth2ErrorException(message) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt index 9c9f3ee6..8d64cbeb 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt @@ -7,6 +7,8 @@ import com.coder.toolbox.util.getOS import okhttp3.Interceptor import java.net.URL +const val CODER_SESSION_TOKEN_HEADER_NAME = "Coder-Session-Token" + /** * Factory of okhttp interceptors */ @@ -19,7 +21,7 @@ object Interceptors { return Interceptor { chain -> chain.proceed( chain.request().newBuilder() - .addHeader("Coder-Session-Token", token) + .addHeader(CODER_SESSION_TOKEN_HEADER_NAME, token) .build() ) } diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index 3678813a..f6fd846b 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -11,10 +11,6 @@ import com.jetbrains.toolbox.api.ui.components.TextType */ class DialogUi(private val context: CoderToolboxContext) { - suspend fun confirm(title: LocalizableString, description: LocalizableString): Boolean { - return context.ui.showOkCancelPopup(title, description, context.i18n.ptrl("Yes"), context.i18n.ptrl("No")) - } - suspend fun ask( title: LocalizableString, description: LocalizableString, diff --git a/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt index 46ae602d..de5213ae 100644 --- a/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt @@ -5,11 +5,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Duration -/** - * Suspends the coroutine until first true value is received. - */ -suspend fun StateFlow.waitForTrue() = this.first { it } - /** * Suspends the coroutine until first false value is received. */ diff --git a/src/main/kotlin/com/coder/toolbox/util/Without.kt b/src/main/kotlin/com/coder/toolbox/util/Without.kt deleted file mode 100644 index a54ce358..00000000 --- a/src/main/kotlin/com/coder/toolbox/util/Without.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.coder.toolbox.util - -/** - * Run block with provided arguments after checking they are all non-null. This - * is to enforce non-null values and should be used to signify developer error. - */ -fun withoutNull( - a: A?, - block: (a: A) -> Z, -): Z { - if (a == null) { - throw Exception("Unexpected null value") - } - return block(a) -} - -/** - * Run block with provided arguments after checking they are all non-null. This - * is to enforce non-null values and should be used to signify developer error. - */ -fun withoutNull( - a: A?, - b: B?, - block: (a: A, b: B) -> Z, -): Z { - if (a == null || b == null) { - throw Exception("Unexpected null value") - } - return block(a, b) -} - -/** - * Run block with provided arguments after checking they are all non-null. This - * is to enforce non-null values and should be used to signify developer error. - */ -fun withoutNull( - a: A?, - b: B?, - c: C?, - d: D?, - block: (a: A, b: B, c: C, d: D) -> Z, -): Z { - if (a == null || b == null || c == null || d == null) { - throw Exception("Unexpected null value") - } - return block(a, b, c, d) -} From 542ff73df49ecd7b8fcb8cafaede432dbce67492 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 30 Apr 2026 22:12:40 +0300 Subject: [PATCH 2/2] address review feedback minor styling and error reporting issues --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 2 +- src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt | 4 ++-- src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 49de06f5..a5e83148 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -159,7 +159,7 @@ class CoderRemoteProvider( context.logger.info("wake-up from an OS sleep was detected") } else { context.logger.error(ex, "workspace polling error encountered") - if (ex is APIResponseException && ex.isTokenExpired || ex is OAuthTokenResponseException) { + if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) { close() context.envPageManager.showPluginEnvironmentsPage() errorBuffer.add(ex) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt index 58384d02..7f83f827 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt @@ -36,7 +36,7 @@ class OAuth2Client(private val context: CoderToolboxContext) { val errorBody = response.errorBody()?.string() val registrationError = - errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) }?.toMessage() ?: "${response.message()}" + errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) }?.toMessage() ?: response.message() val errorMessage = "OAuth2 client registration failed with status ${response.code()}: $registrationError" throw ClientRegistrationException(errorMessage) } @@ -112,7 +112,7 @@ class OAuth2Client(private val context: CoderToolboxContext) { } val errorBody = response.errorBody()?.string() - val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) }?.toMessage() ?: "${response.message()}" + val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) }?.toMessage() ?: response.message() val errorMessage = "Failed to $action. Response code: ${response.code()} $tokenError" throw OAuthTokenResponseException(errorMessage) } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 9137a5f5..1d0f39c3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -373,6 +373,7 @@ open class CoderRestClient( true } catch (e: Exception) { context.logger.error(e, "Failed to refresh access token") + // propagate the exception to the main workspace polling loop throw e } } @@ -426,7 +427,7 @@ open class CoderRestClient( } } catch (ex: Exception) { context.logger.error(ex, "Failed to execute refresh command") - throw ex + false } }