From 8bfee5e518443daca339e44f3dac562e8e8aebb0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 9 Oct 2025 22:52:37 +0300 Subject: [PATCH 01/40] build: simplify install folder resolution --- build.gradle.kts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index cdfc5e85..f466871d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -202,21 +202,13 @@ tasks.register("cleanAll", Delete::class.java) { private fun getPluginInstallDir(): Path { val userHome = System.getProperty("user.home").let { Path.of(it) } - val toolboxCachesDir = when { + val pluginsDir = when { SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") // currently this is the location that TBA uses on Linux SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") SystemInfoRt.isMac -> userHome / "Library" / "Caches" else -> error("Unknown os") - } / "JetBrains" / "Toolbox" - - val pluginsDir = when { - SystemInfoRt.isWindows || - SystemInfoRt.isLinux || - SystemInfoRt.isMac -> toolboxCachesDir - - else -> error("Unknown os") - } / "plugins" + } / "JetBrains" / "Toolbox" / "plugins" return pluginsDir / extension.id } From 1a3415b8cb11cfe0b1f55ad9411d86785e0563d4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 9 Oct 2025 23:00:51 +0300 Subject: [PATCH 02/40] impl: setup auth manager with auth and token endpoints Toolbox API comes with a basic oauth2 client. This commit sets-up details about two important oauth flows: - authorization flow, in which the user is sent to web page where an authorization code is generated which is exchanged for an access token. - details about token refresh endpoint where users can obtain a new access token and a new refresh token. A couple of important aspects: - the client app id is resolved in upstream - as well as the actual endpoints for authorization and token refresh - S256 is the only code challenge supported --- .../toolbox/oauth/AuthorizationServer.kt | 22 +++++++ .../com/coder/toolbox/oauth/CoderAccount.kt | 5 ++ .../coder/toolbox/oauth/CoderOAuthManager.kt | 60 +++++++++++++++++++ .../com/coder/toolbox/util/URLExtensions.kt | 9 +++ .../coder/toolbox/util/URLExtensionsTest.kt | 34 +++++++++++ 5 files changed, 130 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt diff --git a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt new file mode 100644 index 00000000..bedf760c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AuthorizationServer( + @field:Json(name = "authorization_endpoint") val authorizationEndpoint: String, + @field:Json(name = "token_endpoint") val tokenEndpoint: String, + @property:Json(name = "token_endpoint_auth_methods_supported") val authMethodForTokenEndpoint: List, +) + +enum class TokenEndpointAuthMethod { + @Json(name = "none") + NONE, + + @Json(name = "client_secret_post") + CLIENT_SECRET_POST, + + @Json(name = "client_secret_basic") + CLIENT_SECRET_BASIC, +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt new file mode 100644 index 00000000..3b3d7877 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt @@ -0,0 +1,5 @@ +package com.coder.toolbox.oauth + +import com.jetbrains.toolbox.api.core.auth.Account + +data class CoderAccount(override val id: String, override val fullName: String) : Account \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt new file mode 100644 index 00000000..9e667e07 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -0,0 +1,60 @@ +package com.coder.toolbox.oauth + +import com.coder.toolbox.util.toBaseURL +import com.jetbrains.toolbox.api.core.auth.AuthConfiguration +import com.jetbrains.toolbox.api.core.auth.ContentType +import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED +import com.jetbrains.toolbox.api.core.auth.OAuthToken +import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface +import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration + +class CoderOAuthManager( + private val clientId: String, + private val authServer: AuthorizationServer +) : PluginAuthInterface { + override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}" + + override fun deserialize(string: String): CoderAccount = CoderAccount( + string.split('|')[0], + string.split('|')[1] + ) + + override suspend fun createAccount( + token: OAuthToken, + config: AuthConfiguration + ): CoderAccount { + TODO("Not yet implemented") + } + + override suspend fun updateAccount( + token: OAuthToken, + account: CoderAccount + ): CoderAccount { + TODO("Not yet implemented") + } + + override fun createAuthConfig(loginConfiguration: CoderLoginCfg): AuthConfiguration = AuthConfiguration( + authParams = mapOf("response_type" to "code", "client_id" to clientId), + tokenParams = mapOf("grant_type" to "authorization_code", "client_id" to clientId), + baseUrl = authServer.authorizationEndpoint.toBaseURL().toString(), + authUrl = authServer.authorizationEndpoint, + tokenUrl = authServer.tokenEndpoint, + codeChallengeParamName = "code_challenge", + codeChallengeMethod = "S256", + verifierParamName = "code_verifier", + authorization = null + ) + + + override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { + return object : RefreshConfiguration { + override val refreshUrl: String = authServer.tokenEndpoint + override val parameters: Map = + mapOf("grant_type" to "refresh_token", "client_id" to clientId) + override val authorization: String? = null + override val contentType: ContentType = FORM_URL_ENCODED + } + } +} + +object CoderLoginCfg \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index 7e2a8e35..2f50ab5a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -8,6 +8,12 @@ import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.toBaseURL(): URL { + val url = this.toURL() + val port = if (url.port != -1) ":${url.port}" else "" + return URI.create("${url.protocol}://${url.host}$port").toURL() +} + fun String.validateStrictWebUrl(): WebUrlValidationResult = try { val uri = URI(this) @@ -21,15 +27,18 @@ fun String.validateStrictWebUrl(): WebUrlValidationResult = try { "The URL \"$this\" is missing a scheme (like https://). " + "Please enter a full web address like \"https://example.com\"" ) + uri.scheme?.lowercase() !in setOf("http", "https") -> Invalid( "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" ) + uri.authority.isNullOrBlank() -> Invalid( "The URL \"$this\" does not include a valid website name. " + "Please enter a full web address like \"https://example.com\"" ) + else -> Valid } } catch (_: Exception) { diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index af1b4efd..5783deec 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -4,6 +4,7 @@ import java.net.URI import java.net.URL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith internal class URLExtensionsTest { @Test @@ -152,4 +153,37 @@ internal class URLExtensionsTest { result ) } + + @Test + fun `returns base URL without path or query`() { + val fullUrl = "https://example.com/path/to/page?param=1" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com"), result) + } + + @Test + fun `includes port if specified`() { + val fullUrl = "https://example.com:8080/api/v1/resource" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com:8080"), result) + } + + @Test + fun `handles subdomains correctly`() { + val fullUrl = "http://api.subdomain.example.org/v2/users" + val result = fullUrl.toBaseURL() + assertEquals(URL("http://api.subdomain.example.org"), result) + } + + @Test + fun `handles simple domain without path`() { + val fullUrl = "https://test.com" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://test.com"), result) + } + + @Test + fun `throws exception for invalid URL`() { + assertFailsWith { "ht!tp://bad_url".toBaseURL() } + } } From 7685febb29035f524896b0e56e0e633a9625f9ad Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 23:38:06 +0300 Subject: [PATCH 03/40] impl: retrieve supported response type and the dynamic client registration url OAuth endpoint `.well-known/oauth-authorization-server` provides metadata about the endpoint for dynamic client registration and supported response types. This commit adds support for deserializing these values. --- src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt index bedf760c..4248ef1f 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt @@ -7,6 +7,8 @@ import com.squareup.moshi.JsonClass data class AuthorizationServer( @field:Json(name = "authorization_endpoint") val authorizationEndpoint: String, @field:Json(name = "token_endpoint") val tokenEndpoint: String, + @field:Json(name = "registration_endpoint") val registrationEndpoint: String, + @property:Json(name = "response_types_supported") val supportedResponseTypes: List, @property:Json(name = "token_endpoint_auth_methods_supported") val authMethodForTokenEndpoint: List, ) From 52648a0285ad028c7ce2d39d70c86c814ea2d696 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 23:43:51 +0300 Subject: [PATCH 04/40] impl: models for dynamic client registration OAuth allows programatic client registration for apps like Coder Toolbox via the DCR endpoint which requires a name for the client app, the requested scopes, redirect URI, etc... DCR replies back with a similar structure but in addition it returs two very important properties: client_id - a unique client identifier string and also a client_secret - a secret string value used by clients to authenticate to the token endpoint. --- .../oauth/ClientRegistrationRequest.kt | 14 +++++++++++++ .../oauth/ClientRegistrationResponse.kt | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt new file mode 100644 index 00000000..d0854d12 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ClientRegistrationRequest( + @field:Json(name = "client_name") val clientName: String, + @field:Json(name = "redirect_uris") val redirectUris: List, + @field:Json(name = "grant_types") val grantTypes: List, + @field:Json(name = "response_types") val responseTypes: List, + @field:Json(name = "scope") val scope: String, + @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String? = null +) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt new file mode 100644 index 00000000..e0d932c0 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -0,0 +1,21 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * DCR response + */ +@JsonClass(generateAdapter = true) +data class ClientRegistrationResponse( + @field:Json(name = "client_id") val clientId: String, + @field:Json(name = "client_secret") val clientSecret: String, + @field:Json(name = "client_name") val clientName: String, + @field:Json(name = "redirect_uris") val redirectUris: List, + @field:Json(name = "grant_types") val grantTypes: List, + @field:Json(name = "response_types") val responseTypes: List, + @field:Json(name = "scope") val scope: String, + @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String, + @field:Json(name = "client_id_issued_at") val clientIdIssuedAt: Long?, + @field:Json(name = "client_secret_expires_at") val clientSecretExpiresAt: Long? +) \ No newline at end of file From 72a902fe7ee17366bcb11dc735e97a623c495a66 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 23:52:33 +0300 Subject: [PATCH 05/40] impl: pixy secure code generator Code Toolbox plugin should protect against authorization code interception attacks by making use of the PKCE security extension which involves a cryptographically random string (128 characters) known as code verifier and a code challenge - derived from code verifier using the S256 challenge method. --- .../com/coder/toolbox/oauth/PKCEGenerator.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt diff --git a/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt b/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt new file mode 100644 index 00000000..fdbefd71 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt @@ -0,0 +1,42 @@ +package com.coder.toolbox.oauth + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +private const val CODE_VERIFIER_LENGTH = 128 + +/** + * Generates OAuth2 PKCE code verifier and code challenge + */ +object PKCEGenerator { + + /** + * Generates a cryptographically random code verifier 128 chars in size + * @return Base64 URL-encoded code verifier + */ + fun generateCodeVerifier(): String { + val secureRandom = SecureRandom() + val bytes = ByteArray(CODE_VERIFIER_LENGTH) + secureRandom.nextBytes(bytes) + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes) + .take(CODE_VERIFIER_LENGTH) + } + + /** + * Generates code challenge from code verifier using S256 method + * @param codeVerifier The code verifier string + * @return Base64 URL-encoded SHA-256 hash of the code verifier + */ + fun generateCodeChallenge(codeVerifier: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(hash) + } +} \ No newline at end of file From 0e03b037cd5b1de407c503190399f485b1873fc7 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 23:53:45 +0300 Subject: [PATCH 06/40] impl: retrofit API for endpoint discovery and dynamic client registration --- .../toolbox/oauth/CoderAuthorizationApi.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt new file mode 100644 index 00000000..ecd9ca92 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt @@ -0,0 +1,18 @@ +package com.coder.toolbox.oauth + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Url + +interface CoderAuthorizationApi { + @GET(".well-known/oauth-authorization-server") + suspend fun discoveryMetadata(): Response + + @POST + suspend fun registerClient( + @Url url: String, + @Body request: ClientRegistrationRequest + ): Response +} \ No newline at end of file From 79ba4cb9c9e0f0cdeb4ef49c9cedaeb1b7f28e3e Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 13 Oct 2025 23:55:43 +0300 Subject: [PATCH 07/40] impl: factory method for the auth manager The OAuth2-compatible authentication manager provided by Toolbox --- .../com/coder/toolbox/CoderToolboxContext.kt | 14 ++++++++++++++ .../com/coder/toolbox/CoderToolboxExtension.kt | 1 + 2 files changed, 15 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index ac3cbcc7..27037192 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,8 +1,13 @@ package com.coder.toolbox +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg +import com.coder.toolbox.oauth.CoderOAuthManager import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.ServiceLocator +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -18,6 +23,7 @@ import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( + private val serviceLocator: ServiceLocator, val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, @@ -47,6 +53,14 @@ data class CoderToolboxContext( ?: settingsStore.defaultURL.toURL() } + fun getAuthManager( + cfg: CoderOAuthCfg + ): PluginAuthManager = serviceLocator.getAuthManager( + accountClass = CoderAccount::class.java, + displayName = "Coder Authentication", + pluginAuthInterface = CoderOAuthManager(cfg) + ) + suspend fun logAndShowError(title: String, error: String) { logger.error(error) ui.showSnackbar( diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5cfcd11f..a5586b08 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -29,6 +29,7 @@ class CoderToolboxExtension : RemoteDevExtension { val logger = serviceLocator.getService(Logger::class.java) return CoderRemoteProvider( CoderToolboxContext( + serviceLocator, serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), From 59d2abd07f0239b8662252fc6a5a8794e930f2ab Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 14 Oct 2025 00:00:17 +0300 Subject: [PATCH 08/40] impl: improve auth manager config - authentication and token endpoints are now passed via the login configuration object - similar for client_id and client_secret - PCKE is now enabled --- .../coder/toolbox/oauth/CoderOAuthManager.kt | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt index 9e667e07..4739068a 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.oauth -import com.coder.toolbox.util.toBaseURL import com.jetbrains.toolbox.api.core.auth.AuthConfiguration import com.jetbrains.toolbox.api.core.auth.ContentType import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED @@ -8,10 +7,7 @@ import com.jetbrains.toolbox.api.core.auth.OAuthToken import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration -class CoderOAuthManager( - private val clientId: String, - private val authServer: AuthorizationServer -) : PluginAuthInterface { +class CoderOAuthManager(private val cfg: CoderOAuthCfg) : PluginAuthInterface { override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}" override fun deserialize(string: String): CoderAccount = CoderAccount( @@ -33,28 +29,49 @@ class CoderOAuthManager( TODO("Not yet implemented") } - override fun createAuthConfig(loginConfiguration: CoderLoginCfg): AuthConfiguration = AuthConfiguration( - authParams = mapOf("response_type" to "code", "client_id" to clientId), - tokenParams = mapOf("grant_type" to "authorization_code", "client_id" to clientId), - baseUrl = authServer.authorizationEndpoint.toBaseURL().toString(), - authUrl = authServer.authorizationEndpoint, - tokenUrl = authServer.tokenEndpoint, - codeChallengeParamName = "code_challenge", - codeChallengeMethod = "S256", - verifierParamName = "code_verifier", - authorization = null - ) + override fun createAuthConfig(loginConfiguration: CoderOAuthCfg): AuthConfiguration { + val codeVerifier = PKCEGenerator.generateCodeVerifier() + val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) + return AuthConfiguration( + authParams = mapOf( + "client_id" to loginConfiguration.clientId, + "response_type" to "code", + "code_challenge" to codeChallenge + ), + tokenParams = mapOf( + "grant_type" to "authorization_code", + "client_id" to loginConfiguration.clientId, + "code_verifier" to codeVerifier + ), + baseUrl = loginConfiguration.baseUrl, + authUrl = loginConfiguration.authUrl, + tokenUrl = loginConfiguration.tokenUrl, + codeChallengeParamName = "code_challenge", + codeChallengeMethod = "S256", + verifierParamName = "code_verifier", + authorization = null + ) + } override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { return object : RefreshConfiguration { - override val refreshUrl: String = authServer.tokenEndpoint - override val parameters: Map = - mapOf("grant_type" to "refresh_token", "client_id" to clientId) + override val refreshUrl: String = cfg.tokenUrl + override val parameters: Map = mapOf( + "grant_type" to "refresh_token", + "client_id" to cfg.clientId, + "client_secret" to cfg.clientSecret + ) override val authorization: String? = null override val contentType: ContentType = FORM_URL_ENCODED } } } -object CoderLoginCfg \ No newline at end of file +data class CoderOAuthCfg( + val baseUrl: String, + val authUrl: String, + val tokenUrl: String, + val clientId: String, + val clientSecret: String, +) \ No newline at end of file From decb082c4f66935c276aa243d89e557675b01859 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 15 Oct 2025 00:15:58 +0300 Subject: [PATCH 09/40] refactor: simplify OAuth manager architecture and improve dependency injection - remove ServiceLocator dependency from CoderToolboxContext - move OAuth manager creation to CoderToolboxExtension for cleaner separation - Refactor CoderOAuthManager to use configuration-based approach instead of constructor injection The idea behind these changes is that createRefreshConfig API does not receive a configuration object that can provide the client id and secret and even the refresh url. So initially we worked around the issue by passing the necessary data via the constructor. However this approach means a couple of things: - the actual auth manager can be created only at a very late stage, when a URL is provided by users - can't easily pass arround the auth manager without coupling the components - have to recreate a new auth manager instance if the user logs out and logs in to a different URL - service locator needs to be passed around because this is the actual factory of oauth managers in Toolbox Instead, we went with a differet approach, COderOAuthManager will derive and store the refresh configs once the authorization config is received. If the user logs out and logs in to a different URL the refresh data is also guaranteed to be updated. And on top of that - this approach allows us to get rid of all of the issues mentioned above. --- .../com/coder/toolbox/CoderToolboxContext.kt | 12 +--------- .../coder/toolbox/CoderToolboxExtension.kt | 8 ++++++- .../coder/toolbox/oauth/CoderOAuthManager.kt | 23 +++++++++++++++---- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 27037192..e56b500d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -2,11 +2,9 @@ package com.coder.toolbox import com.coder.toolbox.oauth.CoderAccount import com.coder.toolbox.oauth.CoderOAuthCfg -import com.coder.toolbox.oauth.CoderOAuthManager import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL -import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager @@ -23,7 +21,7 @@ import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( - private val serviceLocator: ServiceLocator, + val oauthManager: PluginAuthManager, val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, @@ -53,14 +51,6 @@ data class CoderToolboxContext( ?: settingsStore.defaultURL.toURL() } - fun getAuthManager( - cfg: CoderOAuthCfg - ): PluginAuthManager = serviceLocator.getAuthManager( - accountClass = CoderAccount::class.java, - displayName = "Coder Authentication", - pluginAuthInterface = CoderOAuthManager(cfg) - ) - suspend fun logAndShowError(title: String, error: String) { logger.error(error) ui.showSnackbar( diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index a5586b08..ea37e4b3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,5 +1,7 @@ package com.coder.toolbox +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthManager import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore @@ -29,7 +31,11 @@ class CoderToolboxExtension : RemoteDevExtension { val logger = serviceLocator.getService(Logger::class.java) return CoderRemoteProvider( CoderToolboxContext( - serviceLocator, + serviceLocator.getAuthManager( + CoderAccount::class.java, + "Coder OAuth2 Manager", + CoderOAuthManager() + ), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt index 4739068a..24882776 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -7,7 +7,9 @@ import com.jetbrains.toolbox.api.core.auth.OAuthToken import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration -class CoderOAuthManager(private val cfg: CoderOAuthCfg) : PluginAuthInterface { +class CoderOAuthManager : PluginAuthInterface { + private lateinit var refreshConf: CoderRefreshConfig + override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}" override fun deserialize(string: String): CoderAccount = CoderAccount( @@ -32,6 +34,7 @@ class CoderOAuthManager(private val cfg: CoderOAuthCfg) : PluginAuthInterface = mapOf( "grant_type" to "refresh_token", - "client_id" to cfg.clientId, - "client_secret" to cfg.clientSecret + "client_id" to refreshConf.clientId, + "client_secret" to refreshConf.clientSecret ) override val authorization: String? = null override val contentType: ContentType = FORM_URL_ENCODED @@ -74,4 +77,16 @@ data class CoderOAuthCfg( val tokenUrl: String, val clientId: String, val clientSecret: String, +) + +private data class CoderRefreshConfig( + val refreshUrl: String, + val clientId: String, + val clientSecret: String, +) + +private fun CoderOAuthCfg.toRefreshConf() = CoderRefreshConfig( + refreshUrl = this.tokenUrl, + clientId = this.clientId, + this.clientSecret ) \ No newline at end of file From d432a766f04b40fdb6cdf59f7240f52646e08aa8 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 15 Oct 2025 00:20:00 +0300 Subject: [PATCH 10/40] fix: inject mocked PluginAuthManager into UTs --- src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt | 4 ++++ src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt | 4 ++++ .../kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7f5c831f..8ecf59e2 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -4,6 +4,8 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ex.MissingVersionException import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.DataGen.Companion.workspace import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.settings.Environment @@ -31,6 +33,7 @@ import com.coder.toolbox.util.getOS import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sha1 import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -75,6 +78,7 @@ private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( + mockk>(), ui, mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 49314c55..9db0032d 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException @@ -20,6 +22,7 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -101,6 +104,7 @@ class CoderRestClientTest { .build() private val context = CoderToolboxContext( + mockk>(), mockk(), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 4a9ef88e..8a1c3000 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,11 +1,14 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.views.CoderSettingsPage +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -43,6 +46,7 @@ internal class CoderProtocolHandlerTest { } private val context = CoderToolboxContext( + mockk>(), mockk(relaxed = true), mockk(), mockk(), From 2a28ceee3f9b0916ff7716ff7e728ca7e72833f6 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 15 Oct 2025 00:22:11 +0300 Subject: [PATCH 11/40] impl: handle the redirect URI Toolbox can handle automatically the exchange of an authorization code with a token by handling the custom URI for oauth. This commit calls the necessary API in the Coder Toolbox URI handling. --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index ed4854cf..40ade7ad 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -318,6 +318,12 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { + + if (context.oauthManager.canHandle(uri)) { + context.oauthManager.handle(uri) + return + } + linkHandler.handle( uri, shouldDoAutoSetup() From 6462f141328ee418197fdabf3acd47701717b05c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 17 Oct 2025 00:20:04 +0300 Subject: [PATCH 12/40] fix: wrong client app registration endpoint POST /api/v2/oauth2-provider/apps is actually for manual admin registration for admin created apps. Programmatic Dynamic Client Registration is done via `POST /oauth2/register`. At the same time I included `registration_access_token` and `registration_client_uri` to use it later in order to refresh the client secret without re-registering the client app. --- .../com/coder/toolbox/oauth/ClientRegistrationResponse.kt | 4 +++- .../kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt index e0d932c0..4ab5d192 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -17,5 +17,7 @@ data class ClientRegistrationResponse( @field:Json(name = "scope") val scope: String, @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String, @field:Json(name = "client_id_issued_at") val clientIdIssuedAt: Long?, - @field:Json(name = "client_secret_expires_at") val clientSecretExpiresAt: Long? + @field:Json(name = "client_secret_expires_at") val clientSecretExpiresAt: Long?, + @field:Json(name = "registration_client_uri") val registrationClientUri: String, + @field:Json(name = "registration_access_token") val registrationAccessToken: String ) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt index ecd9ca92..8c7e3fe1 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt @@ -4,15 +4,13 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST -import retrofit2.http.Url interface CoderAuthorizationApi { @GET(".well-known/oauth-authorization-server") suspend fun discoveryMetadata(): Response - @POST + @POST("oauth2/register") suspend fun registerClient( - @Url url: String, @Body request: ClientRegistrationRequest ): Response } \ No newline at end of file From 0e46da05c60045a914ac95a041c7eafeba7f5f3f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 17 Oct 2025 00:23:30 +0300 Subject: [PATCH 13/40] impl: simple way of triggering the OAuth flow. A bunch of code thrown around to launch the OAuth flow. Still needs a couple of things: - persist the client id and registration uri and token - re-use client id instead of re-register every time - properly handle scenarios where OAuth is not available - the OAuth right now can be enabled if we log out and then hit next in the deployment screen --- .../coder/toolbox/views/DeploymentUrlStep.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 27e53f97..d51c0999 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,6 +1,14 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.browser.browse +import com.coder.toolbox.oauth.ClientRegistrationRequest +import com.coder.toolbox.oauth.CoderAuthorizationApi +import com.coder.toolbox.oauth.CoderOAuthCfg +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl @@ -14,8 +22,12 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory import java.net.MalformedURLException import java.net.URL @@ -42,6 +54,16 @@ class DeploymentUrlStep( private val errorField = ValidationErrorField(context.i18n.pnotr("")) + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) + + override val panel: RowGroup get() { if (!context.settingsStore.disableSignatureVerification) { @@ -86,6 +108,61 @@ class DeploymentUrlStep( errorReporter.report("URL is invalid", e) return false } + val service = Retrofit.Builder() + .baseUrl(CoderCliSetupContext.url!!) + .client(okHttpClient) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + context.cs.launch { + context.logger.info(">> checking if Coder supports OAuth2") + val response = service.discoveryMetadata() + if (response.isSuccessful) { + val authServer = requireNotNull(response.body()) { + "Successful response returned null body or oauth server discovery metadata" + } + context.logger.info(">> registering coder-jetbrains-toolbox as client app $response") + val clientResponse = service.registerClient( + ClientRegistrationRequest( + clientName = "coder-jetbrains-toolbox", + redirectUris = listOf("jetbrains://gateway/com.coder.toolbox/auth"),//URLEncoder.encode("jetbrains://gateway/com.coder.toolbox/oauth", StandardCharsets.UTF_8.toString())), + grantTypes = listOf("authorization_code", "refresh_token"), + responseTypes = authServer.supportedResponseTypes, + scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", + tokenEndpointAuthMethod = "client_secret_post" + ) + ) + if (clientResponse.isSuccessful) { + val clientResponse = + requireNotNull(clientResponse.body()) { "Successful response returned null body or client registration metadata" } + context.logger.info(">> initiating oauth login with $clientResponse") + + val oauthCfg = CoderOAuthCfg( + baseUrl = CoderCliSetupContext.url!!.toString(), + authUrl = authServer.authorizationEndpoint, + tokenUrl = authServer.tokenEndpoint, + clientId = clientResponse.clientId, + clientSecret = clientResponse.clientSecret, + ) + + val loginUrl = context.oauthManager.initiateLogin(oauthCfg) + context.logger.info(">> retrieving token") + context.desktop.browse(loginUrl) { + context.ui.showErrorInfoPopup(it) + } + val token = context.oauthManager.getToken("coder", forceRefresh = false) + context.logger.info(">> token is $token") + } else { + context.logger.error(">> ${clientResponse.code()} ${clientResponse.message()} || ${clientResponse.errorBody()}") + } + } + } + if (context.settingsStore.requireTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { From 17b859d3d7d81c7e0267e7062149f88ab8fd8d9c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 29 Oct 2025 22:59:15 +0200 Subject: [PATCH 14/40] impl: add config to enforce auth via API token A new config `preferAuthViaApiToken` allows users to continue to use API tokens for authentication when OAuth2 is available on the Coder deployment. --- .../com/coder/toolbox/settings/ReadOnlyCoderSettings.kt | 7 ++++++- .../kotlin/com/coder/toolbox/store/CoderSettingsStore.kt | 2 ++ src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 9ac6438e..5f8cc94e 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -137,12 +137,17 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? - /** * The path where network information for SSH hosts are stored */ val networkInfoDir: String + /** + * Indicates whether API tokens should be used for authentication instead + * of OAuth2 when OAuth2 is available. Defaults to false. + */ + val preferAuthViaApiToken: Boolean + /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 66706ca9..9903ba16 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -79,6 +79,8 @@ class CoderSettingsStore( .resolve("ssh-network-metrics") .normalize() .toString() + override val preferAuthViaApiToken: Boolean + get() = store[PREFER_AUTH_VIA_API_TOKEN]?.toBooleanStrictOrNull() ?: false /** * Where the specified deployment should put its data. diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 555c6b5c..220a152c 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -48,3 +48,4 @@ internal const val NETWORK_INFO_DIR = "networkInfoDir" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" +internal const val PREFER_AUTH_VIA_API_TOKEN = "preferAuthViaApiToken" From 408cdc456f336ec12d27473dbb2eeed9801fde67 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 4 Feb 2026 00:30:57 +0200 Subject: [PATCH 15/40] impl: resolve the account Account implementation with logic to resolve the account once the token is retrieved. Marshalling logic for the account is also added. There is a limitation in the Toolbox API where createRefreshConfig is not receiving the auth params. We worked around by capturing and storing these params in the createAuthConfig but this is unreliable. Instead we use the account to pass the missing info around. --- .../com/coder/toolbox/oauth/CoderAccount.kt | 8 +- .../coder/toolbox/oauth/CoderOAuthManager.kt | 82 +++++++++++++------ .../com/coder/toolbox/sdk/v2/models/User.kt | 2 + .../coder/toolbox/views/DeploymentUrlStep.kt | 7 +- 4 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt index 3b3d7877..d63a904f 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt @@ -2,4 +2,10 @@ package com.coder.toolbox.oauth import com.jetbrains.toolbox.api.core.auth.Account -data class CoderAccount(override val id: String, override val fullName: String) : Account \ No newline at end of file +data class CoderAccount( + override val id: String, + override val fullName: String, + val baseUrl: String, + val refreshUrl: String, + val clientId: String +) : Account \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt index 24882776..13c020ae 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -1,40 +1,84 @@ package com.coder.toolbox.oauth +import com.coder.toolbox.sdk.convertors.UUIDConverter +import com.coder.toolbox.sdk.v2.CoderV2RestFacade +import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.auth.AuthConfiguration import com.jetbrains.toolbox.api.core.auth.ContentType import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED import com.jetbrains.toolbox.api.core.auth.OAuthToken import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.URL class CoderOAuthManager : PluginAuthInterface { - private lateinit var refreshConf: CoderRefreshConfig + private val moshi = Moshi.Builder().add(UUIDConverter()).build() - override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}" + override fun serialize(account: CoderAccount): String { + val adapter = moshi.adapter(CoderAccount::class.java) + return adapter.toJson(account) + } - override fun deserialize(string: String): CoderAccount = CoderAccount( - string.split('|')[0], - string.split('|')[1] - ) + override fun deserialize(string: String): CoderAccount { + val adapter = moshi.adapter(CoderAccount::class.java) + return adapter.fromJson(string) ?: throw IllegalArgumentException("Invalid CoderAccount JSON") + } override suspend fun createAccount( token: OAuthToken, config: AuthConfiguration ): CoderAccount { - TODO("Not yet implemented") + val user = fetchUser(token, config.baseUrl.toURL()) + return CoderAccount( + user.id.toString(), + user.username, + config.baseUrl, + config.tokenUrl, + config.tokenParams["client_id"]!! + ) } override suspend fun updateAccount( token: OAuthToken, account: CoderAccount ): CoderAccount { - TODO("Not yet implemented") + val user = fetchUser(token, account.baseUrl.toURL()) + return CoderAccount( + user.id.toString(), + user.username, + account.baseUrl, + account.refreshUrl, + account.clientId + ) + } + + private suspend fun fetchUser(token: OAuthToken, baseUrl: URL): com.coder.toolbox.sdk.v2.models.User { + val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Authorization", token.authorizationHeader) + .build() + chain.proceed(request) + } + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(httpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + val service = retrofit.create(CoderV2RestFacade::class.java) + return service.me().body() ?: throw IllegalStateException("Could not fetch user info") } override fun createAuthConfig(loginConfiguration: CoderOAuthCfg): AuthConfiguration { val codeVerifier = PKCEGenerator.generateCodeVerifier() val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) - refreshConf = loginConfiguration.toRefreshConf() return AuthConfiguration( authParams = mapOf( @@ -59,11 +103,10 @@ class CoderOAuthManager : PluginAuthInterface { override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { return object : RefreshConfiguration { - override val refreshUrl: String = refreshConf.refreshUrl + override val refreshUrl: String = account.baseUrl override val parameters: Map = mapOf( "grant_type" to "refresh_token", - "client_id" to refreshConf.clientId, - "client_secret" to refreshConf.clientSecret + "client_id" to account.clientId ) override val authorization: String? = null override val contentType: ContentType = FORM_URL_ENCODED @@ -75,18 +118,5 @@ data class CoderOAuthCfg( val baseUrl: String, val authUrl: String, val tokenUrl: String, - val clientId: String, - val clientSecret: String, -) - -private data class CoderRefreshConfig( - val refreshUrl: String, - val clientId: String, - val clientSecret: String, -) - -private fun CoderOAuthCfg.toRefreshConf() = CoderRefreshConfig( - refreshUrl = this.tokenUrl, - clientId = this.clientId, - this.clientSecret + val clientId: String ) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/User.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/User.kt index 00118b2b..aa643bc8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/User.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/User.kt @@ -2,8 +2,10 @@ package com.coder.toolbox.sdk.v2.models import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.util.UUID @JsonClass(generateAdapter = true) data class User( + @Json(name = "id") val id: UUID, @Json(name = "username") val username: String, ) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index f24d00e6..9ed9c2f6 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.interceptors.Interceptors +import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl @@ -60,10 +61,10 @@ class DeploymentUrlStep( } val okHttpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) ) - override val panel: RowGroup get() { if (!context.settingsStore.disableSignatureVerification) { @@ -127,6 +128,7 @@ class DeploymentUrlStep( "Successful response returned null body or oauth server discovery metadata" } context.logger.info(">> registering coder-jetbrains-toolbox as client app $response") + // TODO - until https://github.com/coder/coder/issues/20370 is delivered val clientResponse = service.registerClient( ClientRegistrationRequest( clientName = "coder-jetbrains-toolbox", @@ -147,7 +149,6 @@ class DeploymentUrlStep( authUrl = authServer.authorizationEndpoint, tokenUrl = authServer.tokenEndpoint, clientId = clientResponse.clientId, - clientSecret = clientResponse.clientSecret, ) val loginUrl = context.oauthManager.initiateLogin(oauthCfg) From dca154349016874485e75cc28d77665e7b013662 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 4 Feb 2026 21:40:36 +0200 Subject: [PATCH 16/40] chore: fix UTs --- src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt index bd8762d8..da6b44e8 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt @@ -75,6 +75,7 @@ class DataGen { ) fun user(): User = User( + UUID.randomUUID(), "tester", ) } From 836f45a89b7ac6caf1398b14890e2495eabe2cd5 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 4 Feb 2026 23:14:55 +0200 Subject: [PATCH 17/40] impl: rework the first login screen and discover if oauth2 is supported OAuth2 should be launched if user prefers is over any other method of auth and if only the server supports it. --- .../com/coder/toolbox/views/ConnectStep.kt | 2 +- .../coder/toolbox/views/DeploymentUrlStep.kt | 136 +++++++++++------- .../com/coder/toolbox/views/TokenStep.kt | 2 +- .../com/coder/toolbox/views/WizardStep.kt | 2 +- 4 files changed, 86 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 3c1c8ef9..9b3f01bd 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -170,7 +170,7 @@ class ConnectStep( } } - override fun onNext(): Boolean { + override suspend fun onNext(): Boolean { return false } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 9ed9c2f6..d34997c8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.browser.browse +import com.coder.toolbox.oauth.AuthorizationServer import com.coder.toolbox.oauth.ClientRegistrationRequest import com.coder.toolbox.oauth.CoderAuthorizationApi import com.coder.toolbox.oauth.CoderOAuthCfg @@ -26,7 +27,6 @@ import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import java.net.MalformedURLException @@ -53,6 +53,11 @@ class DeploymentUrlStep( context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + private val preferOAuth2IfAvailable = CheckboxField( + true, + context.i18n.ptrl("Prefer OAuth2 if available over authentication via API Key") + ) + private val errorField = ValidationErrorField(context.i18n.pnotr("")) val interceptors = buildList { @@ -72,6 +77,7 @@ class DeploymentUrlStep( RowGroup.RowField(urlField), RowGroup.RowField(emptyLine), RowGroup.RowField(signatureFallbackStrategyField), + RowGroup.RowField(preferOAuth2IfAvailable), RowGroup.RowField(errorField) ) @@ -96,21 +102,63 @@ class DeploymentUrlStep( errorReporter.flush() } - override fun onNext(): Boolean { + override suspend fun onNext(): Boolean { context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) - val url = urlField.textState.value - if (url.isBlank()) { + val urlString = urlField.textState.value + if (urlString.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } + try { - CoderCliSetupContext.url = validateRawUrl(url) + CoderCliSetupContext.url = validateRawUrl(urlString) } catch (e: MalformedURLException) { errorReporter.report("URL is invalid", e) return false } - val service = Retrofit.Builder() - .baseUrl(CoderCliSetupContext.url!!) + + if (preferOAuth2IfAvailable.checkedState.value) { + try { + handleOAuth2(urlString) + return false + } catch (e: Exception) { + errorReporter.report("Failed to check OAuth support: ${e.message}", e) + } + } + + if (context.settingsStore.requiresTokenAuth) { + CoderCliSetupWizardState.goToNextStep() + } else { + CoderCliSetupWizardState.goToLastStep() + } + return true + } + + private suspend fun handleOAuth2(urlString: String) { + val service = createAuthorizationService(urlString) + val authServer = fetchDiscoveryMetadata(service) ?: return + + context.logger.info(">> registering coder-jetbrains-toolbox as client app") + val clientResponse = registerClient(service, authServer) ?: return + + context.logger.info(">> initiating oauth login with $clientResponse") + val oauthCfg = CoderOAuthCfg( + baseUrl = CoderCliSetupContext.url!!.toString(), + authUrl = authServer.authorizationEndpoint, + tokenUrl = authServer.tokenEndpoint, + clientId = clientResponse.clientId, + ) + + val loginUrl = context.oauthManager.initiateLogin(oauthCfg) + context.logger.info(">> launching browser for login") + context.desktop.browse(loginUrl) { + context.ui.showErrorInfoPopup(it) + } + } + + private fun createAuthorizationService(urlString: String): CoderAuthorizationApi { + return Retrofit.Builder() + .baseUrl(urlString) .client(okHttpClient) .addConverterFactory( LoggingConverterFactory.wrap( @@ -120,56 +168,38 @@ class DeploymentUrlStep( ) .build() .create(CoderAuthorizationApi::class.java) - context.cs.launch { - context.logger.info(">> checking if Coder supports OAuth2") - val response = service.discoveryMetadata() - if (response.isSuccessful) { - val authServer = requireNotNull(response.body()) { - "Successful response returned null body or oauth server discovery metadata" - } - context.logger.info(">> registering coder-jetbrains-toolbox as client app $response") - // TODO - until https://github.com/coder/coder/issues/20370 is delivered - val clientResponse = service.registerClient( - ClientRegistrationRequest( - clientName = "coder-jetbrains-toolbox", - redirectUris = listOf("jetbrains://gateway/com.coder.toolbox/auth"),//URLEncoder.encode("jetbrains://gateway/com.coder.toolbox/oauth", StandardCharsets.UTF_8.toString())), - grantTypes = listOf("authorization_code", "refresh_token"), - responseTypes = authServer.supportedResponseTypes, - scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", - tokenEndpointAuthMethod = "client_secret_post" - ) - ) - if (clientResponse.isSuccessful) { - val clientResponse = - requireNotNull(clientResponse.body()) { "Successful response returned null body or client registration metadata" } - context.logger.info(">> initiating oauth login with $clientResponse") - - val oauthCfg = CoderOAuthCfg( - baseUrl = CoderCliSetupContext.url!!.toString(), - authUrl = authServer.authorizationEndpoint, - tokenUrl = authServer.tokenEndpoint, - clientId = clientResponse.clientId, - ) - - val loginUrl = context.oauthManager.initiateLogin(oauthCfg) - context.logger.info(">> retrieving token") - context.desktop.browse(loginUrl) { - context.ui.showErrorInfoPopup(it) - } - val token = context.oauthManager.getToken("coder", forceRefresh = false) - context.logger.info(">> token is $token") - } else { - context.logger.error(">> ${clientResponse.code()} ${clientResponse.message()} || ${clientResponse.errorBody()}") - } - } + } + + private suspend fun fetchDiscoveryMetadata(service: CoderAuthorizationApi): AuthorizationServer? { + val response = service.discoveryMetadata() + if (response.isSuccessful) { + return response.body() } + return null + } - if (context.settingsStore.requiresTokenAuth) { - CoderCliSetupWizardState.goToNextStep() + private suspend fun registerClient( + service: CoderAuthorizationApi, + authServer: AuthorizationServer + ): com.coder.toolbox.oauth.ClientRegistrationResponse? { + // TODO - until https://github.com/coder/coder/issues/20370 is delivered + val clientResponse = service.registerClient( + ClientRegistrationRequest( + clientName = "coder-jetbrains-toolbox", + redirectUris = listOf("jetbrains://gateway/com.coder.toolbox/auth"), + grantTypes = listOf("authorization_code", "refresh_token"), + responseTypes = authServer.supportedResponseTypes, + scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", + tokenEndpointAuthMethod = "client_secret_post" + ) + ) + + if (clientResponse.isSuccessful) { + return requireNotNull(clientResponse.body()) { "Successful response returned null body or client registration metadata" } } else { - CoderCliSetupWizardState.goToLastStep() + context.logger.error(">> ${clientResponse.code()} ${clientResponse.message()} || ${clientResponse.errorBody()}") + return null } - return true } /** diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index b449f40a..e0b92568 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -50,7 +50,7 @@ class TokenStep( } } - override fun onNext(): Boolean { + override suspend fun onNext(): Boolean { val token = tokenField.textState.value if (token.isBlank()) { errorField.textState.update { context.i18n.ptrl("Token is required") } diff --git a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt index bb192818..5188fb0d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt @@ -14,6 +14,6 @@ interface WizardStep { * Callback when user hits next. * Returns true if it moved the wizard one step forward. */ - fun onNext(): Boolean + suspend fun onNext(): Boolean fun onBack() } \ No newline at end of file From 6aeaf6822361f42c978ad7eaee80eb7b2c90ac8f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 4 Feb 2026 23:26:05 +0200 Subject: [PATCH 18/40] impl: prefer client_secret_post as token auth method if available Fallback on client_secret_basic or None depending on what the Coder server supports. --- .../kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index d34997c8..ec333da7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -6,6 +6,7 @@ import com.coder.toolbox.oauth.AuthorizationServer import com.coder.toolbox.oauth.ClientRegistrationRequest import com.coder.toolbox.oauth.CoderAuthorizationApi import com.coder.toolbox.oauth.CoderOAuthCfg +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.convertors.LoggingConverterFactory @@ -190,7 +191,13 @@ class DeploymentUrlStep( grantTypes = listOf("authorization_code", "refresh_token"), responseTypes = authServer.supportedResponseTypes, scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", - tokenEndpointAuthMethod = "client_secret_post" + tokenEndpointAuthMethod = if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_POST)) { + "client_secret_post" + } else if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_BASIC)) { + "client_secret_basic" + } else { + "none" + } ) ) From eaaa88b3a3c003f773f905f06f7c3243609e7649 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 4 Feb 2026 23:43:54 +0200 Subject: [PATCH 19/40] fix: missing client secret from authorization request --- .../com/coder/toolbox/oauth/CoderAccount.kt | 5 ++- .../coder/toolbox/oauth/CoderOAuthManager.kt | 35 ++++++++++++------- .../coder/toolbox/views/DeploymentUrlStep.kt | 1 + 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt index d63a904f..b664f863 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt @@ -1,11 +1,14 @@ package com.coder.toolbox.oauth import com.jetbrains.toolbox.api.core.auth.Account +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class CoderAccount( override val id: String, override val fullName: String, val baseUrl: String, val refreshUrl: String, - val clientId: String + val clientId: String, + val clientSecret: String? = null ) : Account \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt index 13c020ae..471db86f 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -38,7 +38,8 @@ class CoderOAuthManager : PluginAuthInterface { user.username, config.baseUrl, config.tokenUrl, - config.tokenParams["client_id"]!! + config.tokenParams["client_id"]!!, + config.tokenParams["client_secret"] ) } @@ -52,7 +53,8 @@ class CoderOAuthManager : PluginAuthInterface { user.username, account.baseUrl, account.refreshUrl, - account.clientId + account.clientId, + account.clientSecret ) } @@ -80,17 +82,22 @@ class CoderOAuthManager : PluginAuthInterface { val codeVerifier = PKCEGenerator.generateCodeVerifier() val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) + val tokenParams = buildMap { + put("grant_type", "authorization_code") + put("client_id", loginConfiguration.clientId) + put("code_verifier", codeVerifier) + + loginConfiguration.clientSecret?.let { + put("client_secret", it) + } + } return AuthConfiguration( authParams = mapOf( "client_id" to loginConfiguration.clientId, "response_type" to "code", "code_challenge" to codeChallenge ), - tokenParams = mapOf( - "grant_type" to "authorization_code", - "client_id" to loginConfiguration.clientId, - "code_verifier" to codeVerifier - ), + tokenParams = tokenParams, baseUrl = loginConfiguration.baseUrl, authUrl = loginConfiguration.authUrl, tokenUrl = loginConfiguration.tokenUrl, @@ -104,10 +111,13 @@ class CoderOAuthManager : PluginAuthInterface { override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { return object : RefreshConfiguration { override val refreshUrl: String = account.baseUrl - override val parameters: Map = mapOf( - "grant_type" to "refresh_token", - "client_id" to account.clientId - ) + override val parameters: Map = buildMap { + put("grant_type", "refresh_token") + put("client_id", account.clientId) + if (account.clientSecret != null) { + put("client_secret", account.clientSecret) + } + } override val authorization: String? = null override val contentType: ContentType = FORM_URL_ENCODED } @@ -118,5 +128,6 @@ data class CoderOAuthCfg( val baseUrl: String, val authUrl: String, val tokenUrl: String, - val clientId: String + val clientId: String, + val clientSecret: String? = null ) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index ec333da7..a88d8358 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -148,6 +148,7 @@ class DeploymentUrlStep( authUrl = authServer.authorizationEndpoint, tokenUrl = authServer.tokenEndpoint, clientId = clientResponse.clientId, + clientSecret = clientResponse.clientSecret ) val loginUrl = context.oauthManager.initiateLogin(oauthCfg) From 033104f78c07b869786bc8352ec2b375e81a2de1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 5 Feb 2026 22:18:05 +0200 Subject: [PATCH 20/40] fix: prefer client_secret_basic auth method As per https://github.com/coder/coder/blob/main/docs/admin/integrations/oauth2-provider.md#client-authentication-methods --- .../kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index a88d8358..a02b052a 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -192,10 +192,10 @@ class DeploymentUrlStep( grantTypes = listOf("authorization_code", "refresh_token"), responseTypes = authServer.supportedResponseTypes, scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", - tokenEndpointAuthMethod = if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_POST)) { - "client_secret_post" - } else if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_BASIC)) { + tokenEndpointAuthMethod = if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_BASIC)) { "client_secret_basic" + } else if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_POST)) { + "client_secret_port" } else { "none" } From 4aac78efa55e7f12bb335f17962f34ecabb4ffc1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 6 Feb 2026 00:24:15 +0200 Subject: [PATCH 21/40] impl: support for client_secret_basic and client_secret_post for token endpoint Based on the auth method type we need to send client id and client secret as a basic auth header or part of the body as an encoded url form --- .../oauth/ClientRegistrationResponse.kt | 2 +- .../coder/toolbox/oauth/CoderOAuthManager.kt | 30 +++++++++++-------- .../coder/toolbox/views/DeploymentUrlStep.kt | 8 +++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt index 4ab5d192..2bcbe1e2 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -15,7 +15,7 @@ data class ClientRegistrationResponse( @field:Json(name = "grant_types") val grantTypes: List, @field:Json(name = "response_types") val responseTypes: List, @field:Json(name = "scope") val scope: String, - @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String, + @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: TokenEndpointAuthMethod, @field:Json(name = "client_id_issued_at") val clientIdIssuedAt: Long?, @field:Json(name = "client_secret_expires_at") val clientSecretExpiresAt: Long?, @field:Json(name = "registration_client_uri") val registrationClientUri: String, diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt index 471db86f..3c43ede5 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -10,6 +10,7 @@ import com.jetbrains.toolbox.api.core.auth.OAuthToken import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration import com.squareup.moshi.Moshi +import okhttp3.Credentials import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory @@ -82,29 +83,32 @@ class CoderOAuthManager : PluginAuthInterface { val codeVerifier = PKCEGenerator.generateCodeVerifier() val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) - val tokenParams = buildMap { - put("grant_type", "authorization_code") - put("client_id", loginConfiguration.clientId) - put("code_verifier", codeVerifier) - - loginConfiguration.clientSecret?.let { - put("client_secret", it) - } - } return AuthConfiguration( authParams = mapOf( "client_id" to loginConfiguration.clientId, "response_type" to "code", "code_challenge" to codeChallenge ), - tokenParams = tokenParams, + tokenParams = buildMap { + put("grant_type", "authorization_code") + put("code", loginConfiguration.clientSecret) + if (loginConfiguration.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_POST) { + put("client_id", loginConfiguration.clientId) + put("client_secret", loginConfiguration.clientSecret) + } + put("redirect_uri", loginConfiguration.redirectUri) + }, baseUrl = loginConfiguration.baseUrl, authUrl = loginConfiguration.authUrl, tokenUrl = loginConfiguration.tokenUrl, codeChallengeParamName = "code_challenge", codeChallengeMethod = "S256", verifierParamName = "code_verifier", - authorization = null + authorization = if (loginConfiguration.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_BASIC) { + Credentials.basic(loginConfiguration.clientId, loginConfiguration.clientSecret) + } else { + null + } ) } @@ -129,5 +133,7 @@ data class CoderOAuthCfg( val authUrl: String, val tokenUrl: String, val clientId: String, - val clientSecret: String? = null + val clientSecret: String, + val tokenAuthMethod: TokenEndpointAuthMethod, + val redirectUri: String ) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index a02b052a..db37dabb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -33,6 +33,8 @@ import retrofit2.converter.moshi.MoshiConverterFactory import java.net.MalformedURLException import java.net.URL +private const val REDIRECT_URI = "jetbrains://gateway/com.coder.toolbox/auth" + /** * A page with a field for providing the Coder deployment URL. * @@ -148,7 +150,9 @@ class DeploymentUrlStep( authUrl = authServer.authorizationEndpoint, tokenUrl = authServer.tokenEndpoint, clientId = clientResponse.clientId, - clientSecret = clientResponse.clientSecret + clientSecret = clientResponse.clientSecret, + tokenAuthMethod = clientResponse.tokenEndpointAuthMethod, + redirectUri = REDIRECT_URI ) val loginUrl = context.oauthManager.initiateLogin(oauthCfg) @@ -188,7 +192,7 @@ class DeploymentUrlStep( val clientResponse = service.registerClient( ClientRegistrationRequest( clientName = "coder-jetbrains-toolbox", - redirectUris = listOf("jetbrains://gateway/com.coder.toolbox/auth"), + redirectUris = listOf(REDIRECT_URI), grantTypes = listOf("authorization_code", "refresh_token"), responseTypes = authServer.supportedResponseTypes, scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", From c333c65d75f350e53ef9700f9fabb25481796e59 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 9 Feb 2026 21:59:11 +0200 Subject: [PATCH 22/40] impl: implement our own OAuth2 client (1) We encountered a couple of issues with the Toolbox API which is inflexible: - we don't have complete control over which parameters are sent as query&body - we don't have fully basic + headers + body logging for debugging purposes - doesn't integrate that well with our existing http client used for polling - spent more than a couple of hours trying to understand why Coder rejects the authorization call with: ``` {"error":"invalid_request","error_description":"The request is missing required parameters or is otherwise malformed"} from Coder server. ``` Instead we will slowly discard the existing logic and rely on enhancements to our existing http client. Basically, the login screen will try to first determine if mTLS auth is configured and use that, otherwise it will check if the user wants to use OAuth over API token, if available. When the flag is true then the login screen will query the Coder server to see if OAuth2 is supported. If that is true then browser is launched pointing to the authentication URL. If not we will default to the API token authentication. --- .../coder/toolbox/views/DeploymentUrlStep.kt | 80 +++++++++++-------- .../views/state/CoderCliSetupContext.kt | 25 +++++- 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index db37dabb..579ebf94 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -5,7 +5,7 @@ import com.coder.toolbox.browser.browse import com.coder.toolbox.oauth.AuthorizationServer import com.coder.toolbox.oauth.ClientRegistrationRequest import com.coder.toolbox.oauth.CoderAuthorizationApi -import com.coder.toolbox.oauth.CoderOAuthCfg +import com.coder.toolbox.oauth.PKCEGenerator import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder @@ -17,6 +17,7 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField @@ -28,16 +29,20 @@ import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrl import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import java.net.MalformedURLException import java.net.URL +import java.util.UUID private const val REDIRECT_URI = "jetbrains://gateway/com.coder.toolbox/auth" +private const val OAUTH2_SCOPE: String = + "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read" /** * A page with a field for providing the Coder deployment URL. - * + *\ * Populates with the provided URL, at which point the user can accept or * enter their own. */ @@ -107,59 +112,69 @@ class DeploymentUrlStep( override suspend fun onNext(): Boolean { context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) - val urlString = urlField.textState.value - if (urlString.isBlank()) { + val rawUrl = urlField.contentState.value + if (rawUrl.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } try { - CoderCliSetupContext.url = validateRawUrl(urlString) + CoderCliSetupContext.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { errorReporter.report("URL is invalid", e) return false } - if (preferOAuth2IfAvailable.checkedState.value) { + if (context.settingsStore.requiresMTlsAuth) { + CoderCliSetupWizardState.goToLastStep() + return true + } + if (context.settingsStore.requiresTokenAuth && preferOAuth2IfAvailable.checkedState.value) { try { - handleOAuth2(urlString) + CoderCliSetupContext.oauthSession = handleOAuth2(rawUrl) return false } catch (e: Exception) { errorReporter.report("Failed to check OAuth support: ${e.message}", e) } } - - if (context.settingsStore.requiresTokenAuth) { - CoderCliSetupWizardState.goToNextStep() - } else { - CoderCliSetupWizardState.goToLastStep() - } + // if all else fails try the good old API token auth + CoderCliSetupWizardState.goToNextStep() return true } - private suspend fun handleOAuth2(urlString: String) { + private suspend fun handleOAuth2(urlString: String): CoderOAuthSessionContext? { val service = createAuthorizationService(urlString) - val authServer = fetchDiscoveryMetadata(service) ?: return + val authServer = fetchDiscoveryMetadata(service) ?: return null + + context.logger.debug("registering coder-jetbrains-toolbox as client app") + val clientResponse = registerClient(service, authServer) ?: return null + + val codeVerifier = PKCEGenerator.generateCodeVerifier() + val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) + val state = UUID.randomUUID().toString() + + val loginUrl = authServer.authorizationEndpoint.toHttpUrl().newBuilder() + .addQueryParameter("client_id", clientResponse.clientId) + .addQueryParameter("response_type", "code") + .addQueryParameter("code_challenge", codeChallenge) + .addQueryParameter("code_challenge_method", "S256") + .addQueryParameter("code_challenge", codeChallenge) + .addQueryParameter("scope", OAUTH2_SCOPE) + .build() + .toString() - context.logger.info(">> registering coder-jetbrains-toolbox as client app") - val clientResponse = registerClient(service, authServer) ?: return + context.logger.info("Launching browser for OAuth2 authentication") + context.desktop.browse(loginUrl) { + context.ui.showErrorInfoPopup(it) + } - context.logger.info(">> initiating oauth login with $clientResponse") - val oauthCfg = CoderOAuthCfg( - baseUrl = CoderCliSetupContext.url!!.toString(), - authUrl = authServer.authorizationEndpoint, - tokenUrl = authServer.tokenEndpoint, + return CoderOAuthSessionContext( clientId = clientResponse.clientId, clientSecret = clientResponse.clientSecret, - tokenAuthMethod = clientResponse.tokenEndpointAuthMethod, - redirectUri = REDIRECT_URI + tokenCodeVerifier = codeVerifier, + state = state, + tokenEndpoint = authServer.tokenEndpoint ) - - val loginUrl = context.oauthManager.initiateLogin(oauthCfg) - context.logger.info(">> launching browser for login") - context.desktop.browse(loginUrl) { - context.ui.showErrorInfoPopup(it) - } } private fun createAuthorizationService(urlString: String): CoderAuthorizationApi { @@ -181,6 +196,7 @@ class DeploymentUrlStep( if (response.isSuccessful) { return response.body() } + context.logger.info("OAuth discovery failed: ${response.code()} ${response.message()} || ${response.errorBody()}") return null } @@ -195,11 +211,11 @@ class DeploymentUrlStep( redirectUris = listOf(REDIRECT_URI), grantTypes = listOf("authorization_code", "refresh_token"), responseTypes = authServer.supportedResponseTypes, - scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", + scope = OAUTH2_SCOPE, tokenEndpointAuthMethod = if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_BASIC)) { "client_secret_basic" } else if (authServer.authMethodForTokenEndpoint.contains(TokenEndpointAuthMethod.CLIENT_SECRET_POST)) { - "client_secret_port" + "client_secret_post" } else { "none" } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt index 8d503b91..f1abf0ca 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -20,6 +20,11 @@ object CoderCliSetupContext { */ var token: String? = null + /** + * The OAuth session context. + */ + var oauthSession: CoderOAuthSessionContext? = null + /** * Returns true if a URL is currently set. */ @@ -30,10 +35,15 @@ object CoderCliSetupContext { */ fun hasToken(): Boolean = !token.isNullOrBlank() + /** + * Returns true if an OAuth access token is currently set. + */ + fun hasOAuthToken(): Boolean = !oauthSession?.accessToken.isNullOrBlank() + /** * Returns true if URL or token is missing and auth is not yet possible. */ - fun isNotReadyForAuth(): Boolean = !(hasUrl() && token != null) + fun isNotReadyForAuth(): Boolean = !(hasUrl() && (hasToken() || hasOAuthToken())) /** * Resets both URL and token to null. @@ -41,5 +51,16 @@ object CoderCliSetupContext { fun reset() { url = null token = null + oauthSession = null } -} \ No newline at end of file +} + +data class CoderOAuthSessionContext( + val clientId: String, + val clientSecret: String, + val tokenCodeVerifier: String, + val state: String, + val tokenEndpoint: String, + var accessToken: String? = null, + var refreshToken: String? = null +) From 63a81bcdd109e2bbb5d3d79e88bb3ff07a37defa Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 9 Feb 2026 23:47:00 +0200 Subject: [PATCH 23/40] impl: implement our own OAuth2 client (2) Same pattern for instantiating the same http client is used in many places. Refactored the setup into a simple factory method. --- .../com/coder/toolbox/cli/CoderCLIManager.kt | 14 ++------------ .../com/coder/toolbox/feed/IdeFeedManager.kt | 13 +------------ .../coder/toolbox/sdk/CoderHttpClientBuilder.kt | 14 ++++++++++++++ .../com/coder/toolbox/views/DeploymentUrlStep.kt | 13 +------------ 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 9963e753..4b81c3d1 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -12,14 +12,11 @@ import com.coder.toolbox.cli.gpg.GPGVerifier import com.coder.toolbox.cli.gpg.VerificationResult import com.coder.toolbox.cli.gpg.VerificationResult.Failed import com.coder.toolbox.cli.gpg.VerificationResult.Invalid -import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder -import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.InvalidVersionException -import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand @@ -148,15 +145,8 @@ class CoderCLIManager( val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") private fun createDownloadService(): CoderDownloadService { - val interceptors = buildList { - add((Interceptors.userAgent(PluginManager.pluginInfo.version))) - add(Interceptors.logging(context)) - } - val okHttpClient = CoderHttpClientBuilder.build( - context, - interceptors, - ReloadableTlsContext(context.settingsStore.readOnly().tls) - ) + + val okHttpClient = CoderHttpClientBuilder.default(context) val retrofit = Retrofit.Builder() .baseUrl(deploymentURL.toString()) diff --git a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt index 2d1e8365..985c11cd 100644 --- a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt @@ -1,10 +1,7 @@ package com.coder.toolbox.feed import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder -import com.coder.toolbox.sdk.interceptors.Interceptors -import com.coder.toolbox.util.ReloadableTlsContext import com.squareup.moshi.Moshi import com.squareup.moshi.Types import kotlinx.coroutines.Dispatchers @@ -43,15 +40,7 @@ class IdeFeedManager( private val feedService: JetBrainsFeedService by lazy { if (feedService != null) return@lazy feedService - val interceptors = buildList { - add((Interceptors.userAgent(PluginManager.pluginInfo.version))) - add(Interceptors.logging(context)) - } - val okHttpClient = CoderHttpClientBuilder.build( - context, - interceptors, - ReloadableTlsContext(context.settingsStore.readOnly().tls) - ) + val okHttpClient = CoderHttpClientBuilder.default(context) val retrofit = Retrofit.Builder() .baseUrl("https://data.services.jetbrains.com/") diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt index a526db05..67d70c29 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.ReloadableTlsContext import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth @@ -48,4 +50,16 @@ object CoderHttpClientBuilder { } return builder.build() } + + fun default(context: CoderToolboxContext): OkHttpClient { + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + return build( + context, + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 579ebf94..2e23e095 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -7,11 +7,8 @@ import com.coder.toolbox.oauth.ClientRegistrationRequest import com.coder.toolbox.oauth.CoderAuthorizationApi import com.coder.toolbox.oauth.PKCEGenerator import com.coder.toolbox.oauth.TokenEndpointAuthMethod -import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.convertors.LoggingConverterFactory -import com.coder.toolbox.sdk.interceptors.Interceptors -import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl @@ -68,15 +65,7 @@ class DeploymentUrlStep( private val errorField = ValidationErrorField(context.i18n.pnotr("")) - val interceptors = buildList { - add((Interceptors.userAgent(PluginManager.pluginInfo.version))) - add(Interceptors.logging(context)) - } - val okHttpClient = CoderHttpClientBuilder.build( - context, - interceptors, - ReloadableTlsContext(context.settingsStore.readOnly().tls) - ) + val okHttpClient = CoderHttpClientBuilder.default(context) override val panel: RowGroup get() { From 33e076dbf188cdf2015534c3400270e86557d752 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 9 Feb 2026 23:57:06 +0200 Subject: [PATCH 24/40] impl: implement our own OAuth2 client (3) Implements the callback for the authentication step. It basically exchanges the auth code for a token response. Supports client_secret_basic as preferred auth method by Coder for the token endpoint, but also client_secret_post if nothing is available. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 101 +++++++++++++++++- .../coder/toolbox/oauth/OAuthTokenResponse.kt | 16 +++ .../coder/toolbox/views/DeploymentUrlStep.kt | 4 +- .../views/state/CoderCliSetupContext.kt | 4 +- 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index cf996a25..68cfa07a 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -3,7 +3,9 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.feed.IdeFeedManager +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus @@ -35,6 +37,7 @@ import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -48,6 +51,7 @@ import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI import java.net.URL +import java.util.Base64 import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds @@ -341,9 +345,8 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - if (context.oauthManager.canHandle(uri)) { - context.oauthManager.handle(uri) - return + if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) { + handleOAuthUri(uri) } val params = uri.toQueryParameters() @@ -407,6 +410,94 @@ class CoderRemoteProvider( } } + private suspend fun handleOAuthUri(uri: URI) { + val params = uri.toQueryParameters() + val code = params["code"] + val state = params["state"] + + if (code != null && state != null && state == CoderCliSetupContext.oauthSession?.state) { + if (CoderCliSetupContext.oauthSession == null) { + context.logAndShowError( + "Failed to handle OAuth code", + "We received an OAuth code but our OAuth session is null" + ) + return + } + exchangeOAuthCodeForToken(code) + } + } + + private suspend fun exchangeOAuthCodeForToken(code: String) { + try { + context.logger.info("Handling OAuth callback...") + val session = CoderCliSetupContext.oauthSession ?: return + + // we need to make a POST request to the token endpoint + val formBodyBuilder = okhttp3.FormBody.Builder() + .add("code", code) + .add("grant_type", "authorization_code") + .add("code_verifier", session.tokenCodeVerifier) + .add("redirect_uri", "jetbrains://gateway/com.coder.toolbox/auth") + + val requestBuilder = okhttp3.Request.Builder() + .url(session.tokenEndpoint) + + when (session.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> { + val credentials = "${session.clientId}:${session.clientSecret}" + val encoded = Base64.getEncoder().encodeToString(credentials.toByteArray()) + requestBuilder.header("Authorization", "Basic $encoded") + } + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> { + formBodyBuilder.add("client_id", session.clientId) + formBodyBuilder.add("client_secret", session.clientSecret) + } + + else -> { + formBodyBuilder.add("client_id", session.clientId) + } + } + + val request = requestBuilder + .post(formBodyBuilder.build()) + .build() + + val response = CoderHttpClientBuilder.default(context) + .newCall(request) + .execute() + + if (!response.isSuccessful) { + context.logAndShowError("OAuth Error", "Failed to exchange code for token") + return + } + + val responseBody = response.body?.string() ?: return + val adapter = Moshi + .Builder() + .build() + .adapter(com.coder.toolbox.oauth.OAuthTokenResponse::class.java) + val tokenResponse = adapter.fromJson(responseBody) ?: return + + session.accessToken = tokenResponse.accessToken + session.refreshToken = tokenResponse.refreshToken + + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() + } + + } catch (e: Exception) { + context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e) + } + } + private suspend fun resolveDeploymentUrl(params: Map): String? { val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { @@ -520,7 +611,9 @@ class CoderRemoteProvider( close() context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { - context.secrets.storeTokenFor(client.url, client.token ?: "") + if (client.token != null) { + context.secrets.storeTokenFor(client.url, client.token) + } context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { context.logger.info("Deployment URL was stored and will be available for automatic connection") diff --git a/src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt new file mode 100644 index 00000000..7c58eddf --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt @@ -0,0 +1,16 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * A successful token response per RFC 6749. + */ +@JsonClass(generateAdapter = true) +data class OAuthTokenResponse( + @field:Json(name = "access_token") val accessToken: String, + @field:Json(name = "token_type") val tokenType: String, + @field:Json(name = "expires_in") val expiresIn: Long?, + @field:Json(name = "refresh_token") val refreshToken: String?, + @field:Json(name = "scope") val scope: String? +) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 2e23e095..4339d9f4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -7,6 +7,7 @@ import com.coder.toolbox.oauth.ClientRegistrationRequest import com.coder.toolbox.oauth.CoderAuthorizationApi import com.coder.toolbox.oauth.PKCEGenerator import com.coder.toolbox.oauth.TokenEndpointAuthMethod +import com.coder.toolbox.oauth.getPreferredOrAvailable import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.util.WebUrlValidationResult.Invalid @@ -162,7 +163,8 @@ class DeploymentUrlStep( clientSecret = clientResponse.clientSecret, tokenCodeVerifier = codeVerifier, state = state, - tokenEndpoint = authServer.tokenEndpoint + tokenEndpoint = authServer.tokenEndpoint, + tokenAuthMethod = authServer.authMethodForTokenEndpoint.getPreferredOrAvailable() ) } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt index f1abf0ca..7555570d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.views.state +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import java.net.URL /** @@ -62,5 +63,6 @@ data class CoderOAuthSessionContext( val state: String, val tokenEndpoint: String, var accessToken: String? = null, - var refreshToken: String? = null + var refreshToken: String? = null, + val tokenAuthMethod: TokenEndpointAuthMethod ) From 35f7624eed27da1df95e71bdf4505acbbec13d1c Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Feb 2026 00:08:27 +0200 Subject: [PATCH 25/40] fix: code challenge was sent twice to the auth endpoint Coder server effectively rejected the request because the challenge was executed twice --- src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 4339d9f4..7b800e99 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -146,7 +146,6 @@ class DeploymentUrlStep( val loginUrl = authServer.authorizationEndpoint.toHttpUrl().newBuilder() .addQueryParameter("client_id", clientResponse.clientId) .addQueryParameter("response_type", "code") - .addQueryParameter("code_challenge", codeChallenge) .addQueryParameter("code_challenge_method", "S256") .addQueryParameter("code_challenge", codeChallenge) .addQueryParameter("scope", OAUTH2_SCOPE) From ba356bc52b1c96d1628aa31c2620193c39d5d488 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Feb 2026 00:10:38 +0200 Subject: [PATCH 26/40] fix: include state for cross-checking later when the auth code is returned --- src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 7b800e99..7690e543 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -149,6 +149,7 @@ class DeploymentUrlStep( .addQueryParameter("code_challenge_method", "S256") .addQueryParameter("code_challenge", codeChallenge) .addQueryParameter("scope", OAUTH2_SCOPE) + .addQueryParameter("state", state) .build() .toString() From 51cd195ee97911aeb9b59a2103ff26dc85226250 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Feb 2026 00:11:37 +0200 Subject: [PATCH 27/40] fix: short circuit the URI handler when handling oauth callbacks --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 68cfa07a..c1c6e6de 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -347,6 +347,7 @@ class CoderRemoteProvider( try { if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) { handleOAuthUri(uri) + return } val params = uri.toQueryParameters() From 6d26d631a1ed5df15571c2fd410e610c69a3c02b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Feb 2026 23:07:15 +0200 Subject: [PATCH 28/40] fix: short circuit the URI handler when handling oauth callbacks (2) Include utility for fallbacks of auth method. --- .../com/coder/toolbox/oauth/AuthorizationServer.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt index 4248ef1f..2077e137 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt @@ -21,4 +21,14 @@ enum class TokenEndpointAuthMethod { @Json(name = "client_secret_basic") CLIENT_SECRET_BASIC, +} + +fun List.getPreferredOrAvailable(): TokenEndpointAuthMethod { + return when { + // secret basic is preferred by coder + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC in this -> TokenEndpointAuthMethod.CLIENT_SECRET_BASIC + TokenEndpointAuthMethod.CLIENT_SECRET_POST in this -> TokenEndpointAuthMethod.CLIENT_SECRET_POST + else -> TokenEndpointAuthMethod.NONE + + } } \ No newline at end of file From 3d42eb0c6420bb5b29a737feb7261ac9e55c8d2f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 10 Feb 2026 23:38:14 +0200 Subject: [PATCH 29/40] impl: implement our own OAuth2 client (4) Initiates the REST API client and the CLI with the resolved token. There is no support for refreshing the token if it expires, for now. --- .../kotlin/com/coder/toolbox/sdk/CoderRestClient.kt | 8 +++++--- .../kotlin/com/coder/toolbox/views/ConnectStep.kt | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index d96e82ad..1f432354 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -42,6 +42,7 @@ open class CoderRestClient( private val context: CoderToolboxContext, val url: URL, val token: String?, + val oauthToken: String?, private val pluginVersion: String = "development", ) { private lateinit var tlsContext: ReloadableTlsContext @@ -70,10 +71,11 @@ open class CoderRestClient( val interceptors = buildList { if (context.settingsStore.requiresTokenAuth) { - if (token.isNullOrBlank()) { - throw IllegalStateException("Token is required for $url deployment") + val oauthOrApiToken = oauthToken ?: token + if (oauthOrApiToken.isNullOrBlank()) { + throw IllegalStateException("OAuth or API token is required for $url deployment") } - add(Interceptors.tokenAuth(token)) + add(Interceptors.tokenAuth(oauthOrApiToken)) } add((Interceptors.userAgent(pluginVersion))) add(Interceptors.externalHeaders(context, url)) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 9b3f01bd..d702ffa5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -73,7 +73,7 @@ class ConnectStep( return } - if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken() && !CoderCliSetupContext.hasOAuthToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -92,7 +92,8 @@ class ConnectStep( context, url, if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, - PluginManager.pluginInfo.version, + if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.hasOAuthToken()) CoderCliSetupContext.oauthSession!!.accessToken else null, + PluginManager.pluginInfo.version ) // allows interleaving with the back/cancel action yield() @@ -109,7 +110,11 @@ class ConnectStep( logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - cli.login(client.token!!) + if (CoderCliSetupContext.hasOAuthToken()) { + cli.login(CoderCliSetupContext.oauthSession!!.accessToken!!) + } else { + cli.login(client.token!!) + } } logAndReportProgress("Successfully configured ${hostName}...") // allows interleaving with the back/cancel action From ea747f19b473926bd8aaa5142b394201d07babeb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 12 Feb 2026 23:36:24 +0200 Subject: [PATCH 30/40] impl: support for token refresh (1) Rough implementation for detecting expired tokens, and calling the token endpoint to refresh and retrieve a new access token but also a new refresh token. If the refresh is successful then we retry the failed call again, but also execute a callback that in subsequent commits should allow the new token to be passed to the cli, and also stored. This is a crude implementation, I think a refactor is needed as there are so many params that are cloned and passed around between the CoderProvider, login steps and rest api client and then back again to the coder remote provider. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 34 +++++--- .../coder/toolbox/oauth/CoderOAuthSession.kt | 15 ++++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 79 ++++++++++++++++++- .../toolbox/views/CoderCliSetupWizardPage.kt | 5 +- .../com/coder/toolbox/views/ConnectStep.kt | 13 +-- .../views/state/CoderCliSetupContext.kt | 10 ++- 6 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index c1c6e6de..83c5e6a9 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -3,6 +3,7 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.feed.IdeFeedManager +import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderHttpClientBuilder @@ -49,9 +50,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import okhttp3.Credentials import java.net.URI import java.net.URL -import java.util.Base64 import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds @@ -381,7 +382,8 @@ class CoderRemoteProvider( initialAutoSetup = true, jumpToMainPageOnError = true, connectSynchronously = true, - onConnect = ::onConnect + onConnect = ::onConnect, + onTokenRefreshed = ::onTokenRefreshed ).apply { beforeShow() } @@ -445,9 +447,7 @@ class CoderRemoteProvider( when (session.tokenAuthMethod) { TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> { - val credentials = "${session.clientId}:${session.clientSecret}" - val encoded = Base64.getEncoder().encodeToString(credentials.toByteArray()) - requestBuilder.header("Authorization", "Basic $encoded") + requestBuilder.header("Authorization", Credentials.basic(session.clientId, session.clientSecret)) } TokenEndpointAuthMethod.CLIENT_SECRET_POST -> { @@ -477,11 +477,10 @@ class CoderRemoteProvider( val adapter = Moshi .Builder() .build() - .adapter(com.coder.toolbox.oauth.OAuthTokenResponse::class.java) + .adapter(OAuthTokenResponse::class.java) val tokenResponse = adapter.fromJson(responseBody) ?: return - session.accessToken = tokenResponse.accessToken - session.refreshToken = tokenResponse.refreshToken + session.tokenResponse = tokenResponse CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) CoderCliSetupWizardPage( @@ -489,7 +488,8 @@ class CoderRemoteProvider( initialAutoSetup = true, jumpToMainPageOnError = true, connectSynchronously = true, - onConnect = ::onConnect + onConnect = ::onConnect, + onTokenRefreshed = ::onTokenRefreshed ).apply { beforeShow() } @@ -531,6 +531,7 @@ class CoderRemoteProvider( context, url, token, + null, PluginManager.pluginInfo.version, ).apply { initializeSession() } val newCli = CoderCLIManager(context, url).apply { @@ -576,7 +577,8 @@ class CoderRemoteProvider( context, settingsPage, visibilityState, initialAutoSetup = true, jumpToMainPageOnError = false, - onConnect = ::onConnect + onConnect = ::onConnect, + onTokenRefreshed = ::onTokenRefreshed ) } catch (ex: Exception) { errorBuffer.add(ex) @@ -587,7 +589,13 @@ class CoderRemoteProvider( // Login flow. val setupWizardPage = - CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) + CoderCliSetupWizardPage( + context, + settingsPage, + visibilityState, + onConnect = ::onConnect, + onTokenRefreshed = ::onTokenRefreshed + ) // We might have navigated here due to a polling error. errorBuffer.forEach { setupWizardPage.notify("Error encountered", it) @@ -607,6 +615,10 @@ class CoderRemoteProvider( fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() + private suspend fun onTokenRefreshed(token: OAuthTokenResponse) { + cli?.login(token.accessToken) + } + private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. close() diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt new file mode 100644 index 00000000..5eb877e7 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CoderOAuthSession( + val clientId: String, + val clientSecret: String, + val tokenCodeVerifier: String, + val state: String, + val tokenEndpoint: String, + var accessToken: String? = null, + var refreshToken: String? = null, + val tokenAuthMethod: TokenEndpointAuthMethod +) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1f432354..29c9727c 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.OAuthTokenResponse +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.LoggingConverterFactory @@ -21,10 +23,17 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceTransition import com.coder.toolbox.util.ReloadableTlsContext +import com.coder.toolbox.views.state.CoderOAuthSessionContext +import com.coder.toolbox.views.state.hasRefreshToken import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okhttp3.Credentials +import okhttp3.FormBody import okhttp3.OkHttpClient +import okhttp3.Request import org.zeroturnaround.exec.ProcessExecutor import retrofit2.Response import retrofit2.Retrofit @@ -42,14 +51,17 @@ open class CoderRestClient( private val context: CoderToolboxContext, val url: URL, val token: String?, - val oauthToken: String?, + private val oauthContext: CoderOAuthSessionContext? = null, private val pluginVersion: String = "development", + private val onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null ) { private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade + private val refreshMutex = Mutex() + lateinit var me: User lateinit var buildVersion: String lateinit var appName: String @@ -71,7 +83,7 @@ open class CoderRestClient( val interceptors = buildList { if (context.settingsStore.requiresTokenAuth) { - val oauthOrApiToken = oauthToken ?: token + val oauthOrApiToken = oauthContext?.tokenResponse?.accessToken ?: token if (oauthOrApiToken.isNullOrBlank()) { throw IllegalStateException("OAuth or API token is required for $url deployment") } @@ -344,8 +356,29 @@ open class CoderRestClient( * Executes a Retrofit call with a retry mechanism specifically for expired certificates. */ private suspend fun callWithRetry(block: suspend () -> Response): Response { - return try { - block() + try { + val response = block() + 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}") { + return@withLock true + } + return@withLock try { + context.logger.info("Access token expired, attempting to refresh...") + refreshToken() + true + } catch (e: Exception) { + context.logger.error(e, "Failed to refresh access token") + false + } + } + if (tokenRefreshed) { + context.logger.info("Retrying request with new token...") + return block() + } + } + return response } catch (e: Exception) { if (context.settingsStore.requiresMTlsAuth && isCertExpired(e)) { context.logger.info("Certificate expired detected. Attempting refresh...") @@ -358,6 +391,44 @@ open class CoderRestClient( } } + private suspend fun refreshToken() { + val requestBuilder = Request.Builder().url(oauthContext!!.tokenEndpoint) + val formBodyBuilder = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", oauthContext.tokenResponse?.refreshToken!!) + + when (oauthContext.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> { + requestBuilder.header( + "Authorization", + Credentials.basic(oauthContext.clientId, oauthContext.clientSecret ?: "") + ) + } + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> { + formBodyBuilder.add("client_id", oauthContext.clientId) + formBodyBuilder.add("client_secret", oauthContext.clientSecret ?: "") + } + + else -> { + formBodyBuilder.add("client_id", oauthContext.clientId) + } + } + + val request = requestBuilder.post(formBodyBuilder.build()).build() + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + throw APIResponseException("refresh token", url, response.code, null) + } + + val responseBody = response.body?.string() + val newAuthResponse = moshi.adapter(OAuthTokenResponse::class.java).fromJson(responseBody!!) + this.oauthContext.tokenResponse = newAuthResponse + onTokenRefreshed?.invoke(newAuthResponse!!) + } + + private fun isCertExpired(e: Exception): Boolean { return (e is javax.net.ssl.SSLHandshakeException || e is javax.net.ssl.SSLPeerUnverifiedException) && e.message?.contains("certificate_expired", ignoreCase = true) == true diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 2c740241..8963970b 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep @@ -23,6 +24,7 @@ class CoderCliSetupWizardPage( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, + onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) private val settingsAction = Action(context, "Settings") { @@ -38,7 +40,8 @@ class CoderCliSetupWizardPage( connectSynchronously = connectSynchronously, visibilityState, refreshWizard = this::displaySteps, - onConnect + onConnect = onConnect, + onTokenRefreshed = onTokenRefreshed ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index d702ffa5..54ec0335 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI +import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderCliSetupContext @@ -34,6 +35,7 @@ class ConnectStep( visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, + private val onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null ) : WizardStep { private var signInJob: Job? = null @@ -73,7 +75,7 @@ class ConnectStep( return } - if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken() && !CoderCliSetupContext.hasOAuthToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken() && !CoderCliSetupContext.hasOAuthSession()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -92,8 +94,9 @@ class ConnectStep( context, url, if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, - if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.hasOAuthToken()) CoderCliSetupContext.oauthSession!!.accessToken else null, - PluginManager.pluginInfo.version + if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.hasOAuthSession()) CoderCliSetupContext.oauthSession!!.copy() else null, + PluginManager.pluginInfo.version, + onTokenRefreshed ) // allows interleaving with the back/cancel action yield() @@ -110,8 +113,8 @@ class ConnectStep( logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - if (CoderCliSetupContext.hasOAuthToken()) { - cli.login(CoderCliSetupContext.oauthSession!!.accessToken!!) + if (CoderCliSetupContext.hasOAuthSession()) { + cli.login(CoderCliSetupContext.oauthSession!!.tokenResponse!!.accessToken) } else { cli.login(client.token!!) } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt index 7555570d..36bebfab 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.views.state +import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.oauth.TokenEndpointAuthMethod import java.net.URL @@ -39,12 +40,12 @@ object CoderCliSetupContext { /** * Returns true if an OAuth access token is currently set. */ - fun hasOAuthToken(): Boolean = !oauthSession?.accessToken.isNullOrBlank() + fun hasOAuthSession(): Boolean = oauthSession?.tokenResponse?.accessToken != null /** * Returns true if URL or token is missing and auth is not yet possible. */ - fun isNotReadyForAuth(): Boolean = !(hasUrl() && (hasToken() || hasOAuthToken())) + fun isNotReadyForAuth(): Boolean = !(hasUrl() && (hasToken() || hasOAuthSession())) /** * Resets both URL and token to null. @@ -62,7 +63,8 @@ data class CoderOAuthSessionContext( val tokenCodeVerifier: String, val state: String, val tokenEndpoint: String, - var accessToken: String? = null, - var refreshToken: String? = null, + var tokenResponse: OAuthTokenResponse? = null, val tokenAuthMethod: TokenEndpointAuthMethod ) + +fun CoderOAuthSessionContext?.hasRefreshToken(): Boolean = this?.tokenResponse?.refreshToken != null \ No newline at end of file From b989b36dd9fbb6894e58ade825251dcfb888dbb0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 16 Feb 2026 22:59:47 +0200 Subject: [PATCH 31/40] chore: rename context class Which was improperly named, it had nothing to do with the CLI. It is used only for the auth setup wizard. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 22 ++++++------- .../toolbox/views/CoderCliSetupWizardPage.kt | 4 +-- .../com/coder/toolbox/views/ConnectStep.kt | 32 +++++++++---------- .../coder/toolbox/views/DeploymentUrlStep.kt | 12 +++---- .../com/coder/toolbox/views/TokenStep.kt | 16 +++++----- ...pContext.kt => CoderSetupWizardContext.kt} | 4 +-- ...izardState.kt => CoderSetupWizardState.kt} | 2 +- 7 files changed, 46 insertions(+), 46 deletions(-) rename src/main/kotlin/com/coder/toolbox/views/state/{CoderCliSetupContext.kt => CoderSetupWizardContext.kt} (93%) rename src/main/kotlin/com/coder/toolbox/views/state/{CoderCliSetupWizardState.kt => CoderSetupWizardState.kt} (96%) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 83c5e6a9..5179d99d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -27,8 +27,8 @@ import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.CoderSetupWizardContext +import com.coder.toolbox.views.state.CoderSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType @@ -270,7 +270,7 @@ class CoderRemoteProvider( lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() + CoderSetupWizardState.goToFirstStep() context.logger.info("Coder plugin is now closed") } @@ -372,11 +372,11 @@ class CoderRemoteProvider( } } } else { - CoderCliSetupContext.apply { + CoderSetupWizardContext.apply { url = newUrl token = newToken } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderSetupWizardState.goToStep(WizardStep.CONNECT) CoderCliSetupWizardPage( context, settingsPage, visibilityState, initialAutoSetup = true, @@ -418,8 +418,8 @@ class CoderRemoteProvider( val code = params["code"] val state = params["state"] - if (code != null && state != null && state == CoderCliSetupContext.oauthSession?.state) { - if (CoderCliSetupContext.oauthSession == null) { + if (code != null && state != null && state == CoderSetupWizardContext.oauthSession?.state) { + if (CoderSetupWizardContext.oauthSession == null) { context.logAndShowError( "Failed to handle OAuth code", "We received an OAuth code but our OAuth session is null" @@ -433,7 +433,7 @@ class CoderRemoteProvider( private suspend fun exchangeOAuthCodeForToken(code: String) { try { context.logger.info("Handling OAuth callback...") - val session = CoderCliSetupContext.oauthSession ?: return + val session = CoderSetupWizardContext.oauthSession ?: return // we need to make a POST request to the token endpoint val formBodyBuilder = okhttp3.FormBody.Builder() @@ -482,7 +482,7 @@ class CoderRemoteProvider( session.tokenResponse = tokenResponse - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderSetupWizardState.goToStep(WizardStep.CONNECT) CoderCliSetupWizardPage( context, settingsPage, visibilityState, initialAutoSetup = true, @@ -568,11 +568,11 @@ class CoderRemoteProvider( // When coming back to the application, initializeSession immediately. if (shouldDoAutoSetup()) { try { - CoderCliSetupContext.apply { + CoderSetupWizardContext.apply { url = context.deploymentUrl token = context.secrets.tokenFor(context.deploymentUrl) } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderSetupWizardState.goToStep(WizardStep.CONNECT) return CoderCliSetupWizardPage( context, settingsPage, visibilityState, initialAutoSetup = true, diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 8963970b..f0d9a36f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -4,7 +4,7 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.CoderSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription @@ -58,7 +58,7 @@ class CoderCliSetupWizardPage( } private fun displaySteps() { - when (CoderCliSetupWizardState.currentStep()) { + when (CoderSetupWizardState.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { listOf(deploymentUrlStep.panel) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 54ec0335..bdf99da2 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -6,8 +6,8 @@ import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.CoderSetupWizardContext +import com.coder.toolbox.views.state.CoderSetupWizardState import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup @@ -54,14 +54,14 @@ class ConnectStep( context.i18n.pnotr("") } - if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } return } - statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url?.host ?: "unknown host"}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderSetupWizardContext.url?.host ?: "unknown host"}...") } connect() } @@ -69,13 +69,13 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = CoderCliSetupContext.url + val url = CoderSetupWizardContext.url if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken() && !CoderCliSetupContext.hasOAuthSession()) { + if (context.settingsStore.requiresTokenAuth && !CoderSetupWizardContext.hasToken() && !CoderSetupWizardContext.hasOAuthSession()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -93,8 +93,8 @@ class ConnectStep( val client = CoderRestClient( context, url, - if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, - if (context.settingsStore.requiresTokenAuth && CoderCliSetupContext.hasOAuthSession()) CoderCliSetupContext.oauthSession!!.copy() else null, + if (context.settingsStore.requiresTokenAuth) CoderSetupWizardContext.token else null, + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) CoderSetupWizardContext.oauthSession!!.copy() else null, PluginManager.pluginInfo.version, onTokenRefreshed ) @@ -113,8 +113,8 @@ class ConnectStep( logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - if (CoderCliSetupContext.hasOAuthSession()) { - cli.login(CoderCliSetupContext.oauthSession!!.tokenResponse!!.accessToken) + if (CoderSetupWizardContext.hasOAuthSession()) { + cli.login(CoderSetupWizardContext.oauthSession!!.tokenResponse!!.accessToken) } else { cli.login(client.token!!) } @@ -125,8 +125,8 @@ class ConnectStep( context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) - CoderCliSetupContext.reset() - CoderCliSetupWizardState.goToFirstStep() + CoderSetupWizardContext.reset() + CoderSetupWizardState.goToFirstStep() context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { @@ -163,17 +163,17 @@ class ConnectStep( */ private fun handleNavigation() { if (shouldAutoLogin.value) { - CoderCliSetupContext.reset() + CoderSetupWizardContext.reset() if (jumpToMainPageOnError) { context.popupPluginMainPage() } else { - CoderCliSetupWizardState.goToFirstStep() + CoderSetupWizardState.goToFirstStep() } } else { if (context.settingsStore.requiresTokenAuth) { - CoderCliSetupWizardState.goToPreviousStep() + CoderSetupWizardState.goToPreviousStep() } else { - CoderCliSetupWizardState.goToFirstStep() + CoderSetupWizardState.goToFirstStep() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 7690e543..993cf67e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -13,9 +13,9 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.CoderOAuthSessionContext +import com.coder.toolbox.views.state.CoderSetupWizardContext +import com.coder.toolbox.views.state.CoderSetupWizardState import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField @@ -109,26 +109,26 @@ class DeploymentUrlStep( } try { - CoderCliSetupContext.url = validateRawUrl(rawUrl) + CoderSetupWizardContext.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { errorReporter.report("URL is invalid", e) return false } if (context.settingsStore.requiresMTlsAuth) { - CoderCliSetupWizardState.goToLastStep() + CoderSetupWizardState.goToLastStep() return true } if (context.settingsStore.requiresTokenAuth && preferOAuth2IfAvailable.checkedState.value) { try { - CoderCliSetupContext.oauthSession = handleOAuth2(rawUrl) + CoderSetupWizardContext.oauthSession = handleOAuth2(rawUrl) return false } catch (e: Exception) { errorReporter.report("Failed to check OAuth support: ${e.message}", e) } } // if all else fails try the good old API token auth - CoderCliSetupWizardState.goToNextStep() + CoderSetupWizardState.goToNextStep() return true } diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index e0b92568..df97a906 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -2,8 +2,8 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.withPath -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.CoderSetupWizardContext +import com.coder.toolbox.views.state.CoderSetupWizardState import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -35,9 +35,9 @@ class TokenStep( errorField.textState.update { context.i18n.pnotr("") } - if (CoderCliSetupContext.hasUrl()) { + if (CoderSetupWizardContext.hasUrl()) { tokenField.textState.update { - context.secrets.tokenFor(CoderCliSetupContext.url!!) ?: "" + context.secrets.tokenFor(CoderSetupWizardContext.url!!) ?: "" } } else { errorField.textState.update { @@ -46,7 +46,7 @@ class TokenStep( } } (linkField.urlState as MutableStateFlow).update { - CoderCliSetupContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + CoderSetupWizardContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -57,12 +57,12 @@ class TokenStep( return false } - CoderCliSetupContext.token = token - CoderCliSetupWizardState.goToNextStep() + CoderSetupWizardContext.token = token + CoderSetupWizardState.goToNextStep() return true } override fun onBack() { - CoderCliSetupWizardState.goToPreviousStep() + CoderSetupWizardState.goToPreviousStep() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt similarity index 93% rename from src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt index 36bebfab..e90dec51 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt @@ -5,13 +5,13 @@ import com.coder.toolbox.oauth.TokenEndpointAuthMethod import java.net.URL /** - * Singleton that holds Coder CLI setup context (URL and token) across multiple + * Singleton that holds Coder setup wizard context (URL and token) across multiple * Toolbox window lifecycle events. * * This ensures that user input (URL and token) is not lost when the Toolbox * window is temporarily closed or recreated. */ -object CoderCliSetupContext { +object CoderSetupWizardContext { /** * The currently entered URL. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt similarity index 96% rename from src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt index 92a08451..5493e9e5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt @@ -8,7 +8,7 @@ package com.coder.toolbox.views.state * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard * to its initial state by creating a new instance. */ -object CoderCliSetupWizardState { +object CoderSetupWizardState { private var currentStep = WizardStep.URL_REQUEST fun currentStep(): WizardStep = currentStep From b01f0c8101f27e58033404fd552c9bb8f8e630c0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 16 Feb 2026 23:18:23 +0200 Subject: [PATCH 32/40] fix: broken UTs --- src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt | 4 ---- src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt | 7 ------- .../kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt | 4 ---- .../kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt | 6 +----- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 337e1a0a..53080677 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,12 +1,9 @@ package com.coder.toolbox -import com.coder.toolbox.oauth.CoderAccount -import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.toURL -import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -22,7 +19,6 @@ import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( - val oauthManager: PluginAuthManager, val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 84a8dace..010789d7 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,7 +1,5 @@ package com.coder.toolbox -import com.coder.toolbox.oauth.CoderAccount -import com.coder.toolbox.oauth.CoderOAuthManager import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore @@ -35,11 +33,6 @@ class CoderToolboxExtension : RemoteDevExtension { val i18n = serviceLocator.getService() return CoderRemoteProvider( CoderToolboxContext( - serviceLocator.getAuthManager( - CoderAccount::class.java, - "Coder OAuth2 Manager", - CoderOAuthManager() - ), ui, serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index e4973e78..15ebfcdd 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -4,8 +4,6 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ex.MissingVersionException import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException -import com.coder.toolbox.oauth.CoderAccount -import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.DataGen.Companion.workspace import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.settings.Environment @@ -34,7 +32,6 @@ import com.coder.toolbox.util.getOS import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sha1 import com.coder.toolbox.util.toURL -import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -79,7 +76,6 @@ private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( - mockk>(), ui, mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index d6ad5014..a91e7baa 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -1,8 +1,6 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.oauth.CoderAccount -import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException @@ -23,7 +21,6 @@ import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs -import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -105,7 +102,6 @@ class CoderRestClientTest { .build() private val context = CoderToolboxContext( - mockk>(), mockk(), mockk(), mockk(), @@ -261,7 +257,7 @@ class CoderRestClientTest { } }, ) - assertEquals(ex.message, "Token is required for https://coder.com deployment") + assertEquals(ex.message, "OAuth or API token is required for https://coder.com deployment") } } From 08842cf95b22702413a40dd51e2f3731bf477c33 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 18 Feb 2026 00:52:41 +0200 Subject: [PATCH 33/40] chore: dependency declared twice in the build system --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 25cfc9be..ce286de2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,11 +64,11 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.moshi) implementation(libs.bundles.bouncycastle) + testImplementation(kotlin("test")) testImplementation(libs.coroutines.test) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) - testImplementation(libs.coroutines.test) } val extension = ExtensionJson( From 1a1d9dc7b55de373d6b930d0a2a346007101126d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 18 Feb 2026 00:55:13 +0200 Subject: [PATCH 34/40] fix: dependingon the JDK vendor and version zt-exec can raise IOException instead of ProcessInitException We observed some UTs fails to a difference in the JDK version used by Gradle (CLI) versus the one used by IntelliJ to run the tests. The zt-exec library attempts to wrap the standard java.io.IOException (thrown when a process fails to start) into a ProcessInitException. It does this by parsing the exception message to extract the error code (e.g., looking for error=2). However, different JDK versions (and distributions) format this error message differently. If the JDK used by IntelliJ produces an error message that zt-exec doesn't recognize (e.g., missing the error= part or using a different format), zt-exec fails to wrap the exception and instead propagates the original IOException. To fix the test so it passes in both environments, we should catch the more general IOException (which is the parent of ProcessInitException) and assert on the essential part of the error message ("No such file or directory") rather than the specific format zt-exec produces. --- .../com/coder/toolbox/cli/CoderCLIManagerTest.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 15ebfcdd..342e1ca1 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -51,7 +51,7 @@ import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException -import org.zeroturnaround.exec.ProcessInitException +import java.io.IOException import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy @@ -333,10 +333,10 @@ internal class CoderCLIManagerTest { URL("https://foo") ) - assertFailsWith( - exceptionClass = ProcessInitException::class, - block = { ccm.login("fake-token") }, - ) + val exception = assertFailsWith { + runBlocking { ccm.login("fake-token") } + } + assertContains(exception.message!!, Regex("Could not execute .*")) } @Test @@ -722,7 +722,7 @@ internal class CoderCLIManagerTest { fun testFailVersionParse() { val tests = mapOf( - null to ProcessInitException::class, + null to IOException::class, echo("""{"foo": true, "baz": 1}""") to MissingVersionException::class, echo("""{"version": ""}""") to MissingVersionException::class, echo("""v0.0.1""") to JsonEncodingException::class, @@ -935,7 +935,7 @@ internal class CoderCLIManagerTest { val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( - exceptionClass = ProcessInitException::class, + exceptionClass = IOException::class, block = { ccm.version() }, ) } From dc8e589e5807c62048afbb3528df0d4e97c1d4cb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 18 Feb 2026 23:01:43 +0200 Subject: [PATCH 35/40] impl: persist refresh token between Toolbox restarts (1) We need the client id, client secret, auth method a refresh token to be persisted so that at the next app restart we can avoid going again through client registration, and authentication and authorization steps. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 12 ++++++----- .../com/coder/toolbox/sdk/CoderRestClient.kt | 4 ++-- .../coder/toolbox/store/CoderSecretsStore.kt | 20 ++++++++++++++++--- .../toolbox/views/CoderCliSetupWizardPage.kt | 5 +++-- .../com/coder/toolbox/views/ConnectStep.kt | 5 +++-- .../com/coder/toolbox/views/TokenStep.kt | 2 +- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 5179d99d..61974f71 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -27,6 +27,7 @@ import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage +import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.CoderSetupWizardContext import com.coder.toolbox.views.state.CoderSetupWizardState import com.coder.toolbox.views.state.WizardStep @@ -570,7 +571,7 @@ class CoderRemoteProvider( try { CoderSetupWizardContext.apply { url = context.deploymentUrl - token = context.secrets.tokenFor(context.deploymentUrl) + token = context.secrets.apiTokenFor(context.deploymentUrl) } CoderSetupWizardState.goToStep(WizardStep.CONNECT) return CoderCliSetupWizardPage( @@ -613,10 +614,11 @@ class CoderRemoteProvider( */ private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) - fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() + fun canAutoLogin(): Boolean = !context.secrets.apiTokenFor(context.deploymentUrl).isNullOrBlank() - private suspend fun onTokenRefreshed(token: OAuthTokenResponse) { - cli?.login(token.accessToken) + private suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) { + oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) } + context.secrets.storeOAuthFor(url.toString(), oauthSessionCtx) } private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { @@ -625,7 +627,7 @@ class CoderRemoteProvider( context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { if (client.token != null) { - context.secrets.storeTokenFor(client.url, client.token) + context.secrets.storeApiTokenFor(client.url, client.token) } context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 29c9727c..7a07b3b2 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -53,7 +53,7 @@ open class CoderRestClient( val token: String?, private val oauthContext: CoderOAuthSessionContext? = null, private val pluginVersion: String = "development", - private val onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null + private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) { private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi @@ -425,7 +425,7 @@ open class CoderRestClient( val responseBody = response.body?.string() val newAuthResponse = moshi.adapter(OAuthTokenResponse::class.java).fromJson(responseBody!!) this.oauthContext.tokenResponse = newAuthResponse - onTokenRefreshed?.invoke(newAuthResponse!!) + onTokenRefreshed?.invoke(url, oauthContext) } diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index a5466b41..be47c5de 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,8 +1,13 @@ package com.coder.toolbox.store +import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.jetbrains.toolbox.api.core.PluginSecretStore import java.net.URL +private const val OAUTH_CLIENT_ID_PREFIX = "oauth-client-id" +private const val OAUTH_CLIENT_SECRET_PREFIX = "oauth-client-secret" +private const val OAUTH_REFRESH_TOKEN = "oauth-refresh-token" +private const val OAUTH_TOKEN_AUTH_METHOD = "oauth-token-auth-method" /** * Provides Coder secrets backed by the secrets store service. @@ -14,9 +19,18 @@ class CoderSecretsStore(private val store: PluginSecretStore) { ) val lastDeploymentURL: String = store["last-deployment-url"] ?: "" - fun tokenFor(url: URL): String? = store[url.host] + fun apiTokenFor(url: URL): String? = store[url.host] - fun storeTokenFor(url: URL, token: String) { - store[url.host] = token + fun storeApiTokenFor(url: URL, apiToken: String) { + store[url.host] = apiToken + } + + fun storeOAuthFor(url: String, oAuthSessionCtx: CoderOAuthSessionContext) { + oAuthSessionCtx.tokenResponse?.refreshToken?.let { refreshToken -> + store["$OAUTH_CLIENT_ID_PREFIX-$url"] = oAuthSessionCtx.clientId + store["$OAUTH_CLIENT_SECRET_PREFIX-$url"] = oAuthSessionCtx.clientSecret + store["$OAUTH_REFRESH_TOKEN-$url"] = refreshToken + store["$OAUTH_TOKEN_AUTH_METHOD-$url"] = oAuthSessionCtx.tokenAuthMethod.name + } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index f0d9a36f..acd75a68 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -2,8 +2,8 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.CoderSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState @@ -12,6 +12,7 @@ import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import java.net.URL class CoderCliSetupWizardPage( private val context: CoderToolboxContext, @@ -24,7 +25,7 @@ class CoderCliSetupWizardPage( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, - onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null + onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) private val settingsAction = Action(context, "Settings") { diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index bdf99da2..6d6aa577 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -3,9 +3,9 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI -import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.CoderSetupWizardContext import com.coder.toolbox.views.state.CoderSetupWizardState import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield +import java.net.URL private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -35,7 +36,7 @@ class ConnectStep( visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, - private val onTokenRefreshed: (suspend (token: OAuthTokenResponse) -> Unit)? = null + private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : WizardStep { private var signInJob: Job? = null diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index df97a906..b50cdec8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -37,7 +37,7 @@ class TokenStep( } if (CoderSetupWizardContext.hasUrl()) { tokenField.textState.update { - context.secrets.tokenFor(CoderSetupWizardContext.url!!) ?: "" + context.secrets.apiTokenFor(CoderSetupWizardContext.url!!) ?: "" } } else { errorField.textState.update { From 50917ea1220f264073606575fa4e523329813c85 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 18 Feb 2026 23:32:13 +0200 Subject: [PATCH 36/40] impl: persist refresh token between Toolbox restarts (2) Save the oauth session details as soon as the http client and cli are initialized. Up until know it happened only on token refresh. --- src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 6d6aa577..88554c62 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -125,7 +125,7 @@ class ConnectStep( yield() context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) - + onTokenRefreshed?.invoke(client.url, CoderSetupWizardContext.oauthSession!!) CoderSetupWizardContext.reset() CoderSetupWizardState.goToFirstStep() context.envPageManager.showPluginEnvironmentsPage() From 9a78aabadf52950e9c3c3ec5b6fd9a7bdfccaa95 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 19 Feb 2026 00:36:20 +0200 Subject: [PATCH 37/40] impl: persist refresh token between Toolbox restarts (3) Load previous oauth session details, and resolve the access token before initializing the cli and rest client. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 21 ++++++- .../toolbox/oauth/CoderAuthorizationApi.kt | 25 +++++++- .../coder/toolbox/store/CoderSecretsStore.kt | 23 ++++++++ .../com/coder/toolbox/views/ConnectStep.kt | 57 +++++++++++++++++++ .../views/state/CoderSetupWizardContext.kt | 8 +++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 61974f71..8529946b 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -569,9 +569,27 @@ class CoderRemoteProvider( // When coming back to the application, initializeSession immediately. if (shouldDoAutoSetup()) { try { + val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString()) CoderSetupWizardContext.apply { url = context.deploymentUrl token = context.secrets.apiTokenFor(context.deploymentUrl) + if (storedOAuthSession != null) { + oauthSession = CoderOAuthSessionContext( + clientId = storedOAuthSession.clientId, + clientSecret = storedOAuthSession.clientSecret, + tokenCodeVerifier = "", + state = "", + tokenEndpoint = storedOAuthSession.tokenEndpoint, + tokenAuthMethod = storedOAuthSession.tokenAuthMethod, + tokenResponse = OAuthTokenResponse( + accessToken = "", + tokenType = "", + expiresIn = null, + refreshToken = storedOAuthSession.refreshToken, + scope = null + ) + ) + } } CoderSetupWizardState.goToStep(WizardStep.CONNECT) return CoderCliSetupWizardPage( @@ -614,7 +632,8 @@ class CoderRemoteProvider( */ private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) - fun canAutoLogin(): Boolean = !context.secrets.apiTokenFor(context.deploymentUrl).isNullOrBlank() + fun canAutoLogin(): Boolean = !context.secrets.apiTokenFor(context.deploymentUrl) + .isNullOrBlank() || context.secrets.oauthSessionFor(context.deploymentUrl.toString()) != null private suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) { oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) } diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt index 8c7e3fe1..f5d40929 100644 --- a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt @@ -2,8 +2,12 @@ package com.coder.toolbox.oauth import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.Url interface CoderAuthorizationApi { @GET(".well-known/oauth-authorization-server") @@ -13,4 +17,23 @@ interface CoderAuthorizationApi { suspend fun registerClient( @Body request: ClientRegistrationRequest ): Response -} \ No newline at end of file + + @FormUrlEncoded + @POST + suspend fun refreshToken( + @Url url: String, + @Field("grant_type") grantType: String = "refresh_token", + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("refresh_token") refreshToken: String + ): Response + + @FormUrlEncoded + @POST + suspend fun refreshToken( + @Url url: String, + @Header("Authorization") authorization: String, + @Field("grant_type") grantType: String = "refresh_token", + @Field("refresh_token") refreshToken: String + ): Response +} diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index be47c5de..f45a6dc3 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.store +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.views.state.CoderOAuthSessionContext +import com.coder.toolbox.views.state.StoredOAuthSession import com.jetbrains.toolbox.api.core.PluginSecretStore import java.net.URL @@ -8,6 +10,7 @@ private const val OAUTH_CLIENT_ID_PREFIX = "oauth-client-id" private const val OAUTH_CLIENT_SECRET_PREFIX = "oauth-client-secret" private const val OAUTH_REFRESH_TOKEN = "oauth-refresh-token" private const val OAUTH_TOKEN_AUTH_METHOD = "oauth-token-auth-method" +private const val OAUTH_TOKEN_ENDPOINT = "oauth-token-endpoint" /** * Provides Coder secrets backed by the secrets store service. @@ -25,12 +28,32 @@ class CoderSecretsStore(private val store: PluginSecretStore) { store[url.host] = apiToken } + fun oauthSessionFor(url: String): StoredOAuthSession? { + val clientId = store["$OAUTH_CLIENT_ID_PREFIX-$url"] + val clientSecret = store["$OAUTH_CLIENT_SECRET_PREFIX-$url"] + val refreshToken = store["$OAUTH_REFRESH_TOKEN-$url"] + val tokenAuthMethod = store["$OAUTH_TOKEN_AUTH_METHOD-$url"] + val tokenEndpoint = store["$OAUTH_TOKEN_ENDPOINT-$url"] + if (clientId == null || clientSecret == null || refreshToken == null || tokenAuthMethod == null || tokenEndpoint == null) { + return null + } + + return StoredOAuthSession( + clientId = clientId, + clientSecret = clientSecret, + refreshToken = refreshToken, + tokenAuthMethod = TokenEndpointAuthMethod.valueOf(tokenAuthMethod), + tokenEndpoint = tokenEndpoint + ) + } + fun storeOAuthFor(url: String, oAuthSessionCtx: CoderOAuthSessionContext) { oAuthSessionCtx.tokenResponse?.refreshToken?.let { refreshToken -> store["$OAUTH_CLIENT_ID_PREFIX-$url"] = oAuthSessionCtx.clientId store["$OAUTH_CLIENT_SECRET_PREFIX-$url"] = oAuthSessionCtx.clientSecret store["$OAUTH_REFRESH_TOKEN-$url"] = refreshToken store["$OAUTH_TOKEN_AUTH_METHOD-$url"] = oAuthSessionCtx.tokenAuthMethod.name + store["$OAUTH_TOKEN_ENDPOINT-$url"] = oAuthSessionCtx.tokenEndpoint } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 88554c62..cf653dc4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -3,8 +3,12 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI +import com.coder.toolbox.oauth.CoderAuthorizationApi +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.CoderSetupWizardContext import com.coder.toolbox.views.state.CoderSetupWizardState @@ -12,6 +16,7 @@ import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import com.squareup.moshi.Moshi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -21,6 +26,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield +import okhttp3.Credentials +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory import java.net.URL private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -90,6 +98,10 @@ class ConnectStep( // 1. Extract the logic into a reusable suspend lambda val connectionLogic: suspend CoroutineScope.() -> Unit = { try { + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) { + refreshOAuthToken(url) + } + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, @@ -154,6 +166,51 @@ class ConnectStep( } } + private suspend fun refreshOAuthToken(url: URL) { + val session = CoderSetupWizardContext.oauthSession ?: return + if (!session.tokenResponse?.accessToken.isNullOrBlank()) return + val refreshToken = session.tokenResponse?.refreshToken ?: return + + logAndReportProgress("Refreshing OAuth token...") + val service = createAuthorizationService(url.toString()) + + val tokenResponse = if (session.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_BASIC) { + service.refreshToken( + url = session.tokenEndpoint, + authorization = Credentials.basic(session.clientId, session.clientSecret), + refreshToken = refreshToken + ) + } else { + service.refreshToken( + url = session.tokenEndpoint, + clientId = session.clientId, + clientSecret = session.clientSecret, + refreshToken = refreshToken + ) + } + + if (tokenResponse.isSuccessful && tokenResponse.body() != null) { + context.logger.info("Successfully refreshed access token") + session.tokenResponse = tokenResponse.body() + } else { + throw Exception("Failed to refresh OAuth token: ${tokenResponse.code()} ${tokenResponse.message()}") + } + } + + private fun createAuthorizationService(urlString: String): CoderAuthorizationApi { + return Retrofit.Builder() + .baseUrl(urlString) + .client(CoderHttpClientBuilder.default(context)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + } + private fun logAndReportProgress(msg: String) { context.logger.info(msg) statusField.textState.update { context.i18n.pnotr(msg) } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt index e90dec51..b924250a 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt @@ -67,4 +67,12 @@ data class CoderOAuthSessionContext( val tokenAuthMethod: TokenEndpointAuthMethod ) +data class StoredOAuthSession( + val clientId: String, + val clientSecret: String, + val refreshToken: String, + val tokenAuthMethod: TokenEndpointAuthMethod, + val tokenEndpoint: String +) + fun CoderOAuthSessionContext?.hasRefreshToken(): Boolean = this?.tokenResponse?.refreshToken != null \ No newline at end of file From 3a624ba32751bed5f12b123f97491743141829ba Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 26 Feb 2026 00:56:58 +0200 Subject: [PATCH 38/40] impl: support logging out and logging back in via OAuth2 A flow like logging out from an existing OAuth2 session and then logging back in fails due to issues with handleUri API which is not able to properly handle UI pages, especially if an existing page was already pushed into the display stack - after log out we have the DeploymentUrl step visible. Once the user hits next the browser is launched where the user authorizes the client app. After which the callback URL pops up the plugin screen which is still showing the deployment url. Normally, in the URI handler we would now push a new page - the ConnectStep. But that doesn't work, the plugin is still stuck with the initial page. After extensive research I found way around by making the login wizard for "listening" to step updates. In other words, once the authorization callback launches the URI, in the handler instead of pushing a new page to the display stack we actually push a new "step" in the existing wizard page. See https://youtrack.jetbrains.com/issue/TBX-16622 for more details. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 85 ++++++++++--------- .../toolbox/views/CoderCliSetupWizardPage.kt | 17 ++-- .../com/coder/toolbox/views/ConnectStep.kt | 44 +++++----- .../coder/toolbox/views/SuspendBiConsumer.kt | 19 +++++ .../views/state/CoderSetupWizardState.kt | 18 ++-- 5 files changed, 107 insertions(+), 76 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 8529946b..26698513 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -20,13 +20,13 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.token import com.coder.toolbox.util.url import com.coder.toolbox.util.validateStrictWebUrl -import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage +import com.coder.toolbox.views.SuspendBiConsumer import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.CoderSetupWizardContext import com.coder.toolbox.views.state.CoderSetupWizardState @@ -54,7 +54,6 @@ import kotlinx.coroutines.selects.select import okhttp3.Credentials import java.net.URI import java.net.URL -import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -85,7 +84,7 @@ class CoderRemoteProvider( private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private val isHandlingUri: AtomicBoolean = AtomicBoolean(false) + private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { client?.let { restClient -> @@ -280,6 +279,7 @@ class CoderRemoteProvider( it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") } + pollJob = null client?.let { it.close() context.logger.info("REST API client closed and resources released") @@ -347,6 +347,9 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { + // Obtain focus. This switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) { handleOAuthUri(uri) return @@ -358,44 +361,35 @@ class CoderRemoteProvider( context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } - isHandlingUri.set(true) - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - coderHeaderPage.isBusy.update { true } context.logger.info("Handling $uri...") val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + coderHeaderPage.isBusy.update { true } if (sameUrl(newUrl, client?.url)) { if (context.settingsStore.requiresTokenAuth) { newToken?.let { refreshSession(newUrl, it) } } + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) } else { + // Different URL - we need a new connection. + // Chain the link handling after onConnect so it runs once the connection is established. CoderSetupWizardContext.apply { url = newUrl token = newToken } CoderSetupWizardState.goToStep(WizardStep.CONNECT) - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, - initialAutoSetup = true, - jumpToMainPageOnError = true, - connectSynchronously = true, - onConnect = ::onConnect, - onTokenRefreshed = ::onTokenRefreshed - ).apply { - beforeShow() - } + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)), + onTokenRefreshed = ::onTokenRefreshed + ) + ) } - // force the poll loop to run - triggerProviderVisible.send(true) - // wait for environments to be populated - isInitialized.waitForTrue() - - linkHandler.handle(params, newUrl, this.client!!, this.cli!!) - coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -409,7 +403,6 @@ class CoderRemoteProvider( context.envPageManager.showPluginEnvironmentsPage() } finally { coderHeaderPage.isBusy.update { false } - isHandlingUri.set(false) firstRun = false } } @@ -484,16 +477,6 @@ class CoderRemoteProvider( session.tokenResponse = tokenResponse CoderSetupWizardState.goToStep(WizardStep.CONNECT) - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, - initialAutoSetup = true, - jumpToMainPageOnError = true, - connectSynchronously = true, - onConnect = ::onConnect, - onTokenRefreshed = ::onTokenRefreshed - ).apply { - beforeShow() - } } catch (e: Exception) { context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e) @@ -561,9 +544,6 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { - if (isHandlingUri.get()) { - return null - } // Show the setup page if we have not configured the client yet. if (client == null) { // When coming back to the application, initializeSession immediately. @@ -596,7 +576,7 @@ class CoderRemoteProvider( context, settingsPage, visibilityState, initialAutoSetup = true, jumpToMainPageOnError = false, - onConnect = ::onConnect, + onConnect = onConnect, onTokenRefreshed = ::onTokenRefreshed ) } catch (ex: Exception) { @@ -612,7 +592,7 @@ class CoderRemoteProvider( context, settingsPage, visibilityState, - onConnect = ::onConnect, + onConnect = onConnect, onTokenRefreshed = ::onTokenRefreshed ) // We might have navigated here due to a polling error. @@ -640,7 +620,7 @@ class CoderRemoteProvider( context.secrets.storeOAuthFor(url.toString(), oauthSessionCtx) } - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private val onConnect: SuspendBiConsumer = SuspendBiConsumer { client, cli -> // Store the URL and token for use next time. close() context.settingsStore.updateLastUsedUrl(client.url) @@ -670,6 +650,27 @@ class CoderRemoteProvider( context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") } + /** + * Returns a [SuspendBiConsumer] that handles the given link parameters. + * Runs in a background coroutine so it doesn't block the connect step's + * post-connection flow. + */ + private fun deferredLinkHandler( + params: Map, + deploymentUrl: URL, + ): SuspendBiConsumer = SuspendBiConsumer { client, cli -> + context.cs.launch(CoroutineName("Deferred Link Handler")) { + try { + linkHandler.handle(params, deploymentUrl, client, cli) + } catch (ex: Exception) { + context.logAndShowError( + "Error handling deferred link", + ex.message ?: "" + ) + } + } + } + private fun MutableStateFlow>>.showLoadingMessage() { this.update { LoadableState.Loading diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index acd75a68..87931115 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -9,9 +9,11 @@ import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import java.net.URL class CoderCliSetupWizardPage( @@ -20,11 +22,7 @@ class CoderCliSetupWizardPage( visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, - connectSynchronously: Boolean = false, - onConnect: suspend ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, + onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) @@ -38,13 +36,13 @@ class CoderCliSetupWizardPage( context, shouldAutoLogin = shouldAutoSetup, jumpToMainPageOnError = jumpToMainPageOnError, - connectSynchronously = connectSynchronously, visibilityState, refreshWizard = this::displaySteps, onConnect = onConnect, onTokenRefreshed = onTokenRefreshed ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) + private var stateCollectJob: Job? = null /** * Fields for this page, displayed in order. @@ -54,7 +52,12 @@ class CoderCliSetupWizardPage( override fun beforeShow() { - displaySteps() + stateCollectJob?.cancel() + stateCollectJob = context.cs.launch { + CoderSetupWizardState.step.collect { + displaySteps() + } + } errorReporter.flush() } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index cf653dc4..02cc5275 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import okhttp3.Credentials import retrofit2.Retrofit @@ -40,10 +39,9 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, - private val connectSynchronously: Boolean, visibilityState: StateFlow, private val refreshWizard: () -> Unit, - private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, + private val onConnect: SuspendBiConsumer, private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : WizardStep { private var signInJob: Job? = null @@ -58,6 +56,7 @@ class ConnectStep( ) override fun onVisible() { + context.logger.info(">> ConnectStep visible") errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") @@ -70,6 +69,12 @@ class ConnectStep( return } + // Don't launch another connection attempt if one is already in progress. + if (signInJob?.isActive == true) { + context.logger.info(">> ConnectStep: connection already in progress, skipping duplicate") + return + } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderSetupWizardContext.url?.host ?: "unknown host"}...") } connect() } @@ -78,6 +83,7 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { + context.logger.info(">> ConnectStep#onConnect called") val url = CoderSetupWizardContext.url if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } @@ -102,12 +108,16 @@ class ConnectStep( refreshOAuthToken(url) } + val oauthSession = + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) CoderSetupWizardContext.oauthSession!!.copy() else null + val apiToken = if (context.settingsStore.requiresTokenAuth) CoderSetupWizardContext.token else null + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, url, - if (context.settingsStore.requiresTokenAuth) CoderSetupWizardContext.token else null, - if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) CoderSetupWizardContext.oauthSession!!.copy() else null, + apiToken, + oauthSession, PluginManager.pluginInfo.version, onTokenRefreshed ) @@ -126,18 +136,21 @@ class ConnectStep( logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - if (CoderSetupWizardContext.hasOAuthSession()) { - cli.login(CoderSetupWizardContext.oauthSession!!.tokenResponse!!.accessToken) + if (oauthSession != null) { + cli.login(oauthSession.tokenResponse!!.accessToken) } else { - cli.login(client.token!!) + cli.login(apiToken!!) } } logAndReportProgress("Successfully configured ${hostName}...") // allows interleaving with the back/cancel action yield() context.logger.info("Connection setup done, initializing the workspace poller...") - onConnect(client, cli) - onTokenRefreshed?.invoke(client.url, CoderSetupWizardContext.oauthSession!!) + onConnect.accept(client, cli) + // Only invoke onTokenRefreshed when we actually have an OAuth session + oauthSession?.let { session -> + onTokenRefreshed?.invoke(client.url, session) + } CoderSetupWizardContext.reset() CoderSetupWizardState.goToFirstStep() context.envPageManager.showPluginEnvironmentsPage() @@ -154,16 +167,7 @@ class ConnectStep( } } - // 2. Choose the execution strategy based on the flag - if (connectSynchronously) { - // Blocks the current thread until connectionLogic completes - runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { - connectionLogic() - } - } else { - // Runs asynchronously using the context's scope - signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) - } + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) } private suspend fun refreshOAuthToken(url: URL) { diff --git a/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt b/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt new file mode 100644 index 00000000..0af9def5 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt @@ -0,0 +1,19 @@ +package com.coder.toolbox.views + +/** + * A suspend variant of [java.util.function.BiConsumer] that supports + * chaining via [andThen]. + */ +@FunctionalInterface +fun interface SuspendBiConsumer { + suspend fun accept(first: T, second: U) + + /** + * Chains this consumer with [next], returning a new [SuspendBiConsumer] + * that executes both in sequence. + */ + fun andThen(next: SuspendBiConsumer): SuspendBiConsumer = SuspendBiConsumer { first, second -> + this.accept(first, second) + next.accept(first, second) + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt index 5493e9e5..317bcc40 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt @@ -1,5 +1,7 @@ package com.coder.toolbox.views.state +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. @@ -9,28 +11,30 @@ package com.coder.toolbox.views.state * to its initial state by creating a new instance. */ object CoderSetupWizardState { - private var currentStep = WizardStep.URL_REQUEST + private val currentStep = MutableStateFlow(WizardStep.URL_REQUEST) + val step: StateFlow = currentStep - fun currentStep(): WizardStep = currentStep + fun currentStep(): WizardStep = currentStep.value fun goToStep(step: WizardStep) { - currentStep = step + currentStep.value = step } fun goToNextStep() { - currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal + 1) % WizardStep.entries.size] + currentStep.value = WizardStep.entries.toTypedArray()[(currentStep.value.ordinal + 1) % WizardStep.entries.size] } fun goToPreviousStep() { - currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal - 1) % WizardStep.entries.size] + val entries = WizardStep.entries.toTypedArray() + currentStep.value = entries[(currentStep.value.ordinal - 1 + entries.size) % entries.size] } fun goToLastStep() { - currentStep = WizardStep.CONNECT + currentStep.value = WizardStep.CONNECT } fun goToFirstStep() { - currentStep = WizardStep.URL_REQUEST + currentStep.value = WizardStep.URL_REQUEST } } From 24424b9f522b5d5aada04d5eedc15a3270ab1d22 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 27 Feb 2026 23:40:42 +0200 Subject: [PATCH 39/40] fix: properly close the setup wizard if connection is successful There were a couple of issues with the existing implementation. ConnectStep was emiting the URL_REQUEST both at the end of the connect but also part of the close() call. This caused the screen to flicker during setup. As a fix close no longer resets the setup global state while a final new step was introduced to allow the ConnectStep to properly close the entire wizard. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 2 +- .../coder/toolbox/views/CoderCliSetupWizardPage.kt | 14 +++++++++++++- .../kotlin/com/coder/toolbox/views/ConnectStep.kt | 2 +- .../toolbox/views/state/CoderSetupWizardState.kt | 8 ++++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 26698513..510a7ce2 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -270,7 +270,6 @@ class CoderRemoteProvider( lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - CoderSetupWizardState.goToFirstStep() context.logger.info("Coder plugin is now closed") } @@ -587,6 +586,7 @@ class CoderRemoteProvider( } // Login flow. + CoderSetupWizardState.goToFirstStep() val setupWizardPage = CoderCliSetupWizardPage( context, diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 87931115..f4391cdb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -54,7 +54,8 @@ class CoderCliSetupWizardPage( override fun beforeShow() { stateCollectJob?.cancel() stateCollectJob = context.cs.launch { - CoderSetupWizardState.step.collect { + CoderSetupWizardState.step.collect { step -> + context.logger.info("Wizard step changed to $step") displaySteps() } } @@ -119,9 +120,20 @@ class CoderCliSetupWizardPage( } connectStep.onVisible() } + + WizardStep.DONE -> { + context.logger.info("Closing the Setup Wizard") + stateCollectJob?.cancel() + context.ui.hideUiPage(this) + CoderSetupWizardState.goToFirstStep() + } } } + override fun afterHide() { + stateCollectJob?.cancel() + } + /** * Show an error as a popup on this page. */ diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 02cc5275..af4b6dd9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -152,7 +152,7 @@ class ConnectStep( onTokenRefreshed?.invoke(client.url, session) } CoderSetupWizardContext.reset() - CoderSetupWizardState.goToFirstStep() + CoderSetupWizardState.goToDone() context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt index 317bcc40..81edd2aa 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt @@ -36,8 +36,12 @@ object CoderSetupWizardState { fun goToFirstStep() { currentStep.value = WizardStep.URL_REQUEST } + + fun goToDone() { + currentStep.value = WizardStep.DONE + } } enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, CONNECT; -} \ No newline at end of file + URL_REQUEST, TOKEN_REQUEST, CONNECT, DONE; +} From ab19d78b133944e1c880f9ef437a9c00a54674c5 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 27 Feb 2026 23:55:10 +0200 Subject: [PATCH 40/40] fix: mark the header bar as busy When switching between Coder deployments using the URI. --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 8 ++++++-- .../kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 510a7ce2..50903001 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -371,6 +371,7 @@ class CoderRemoteProvider( } } linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } else { // Different URL - we need a new connection. // Chain the link handling after onConnect so it runs once the connection is established. @@ -384,7 +385,10 @@ class CoderRemoteProvider( context, settingsPage, visibilityState, initialAutoSetup = true, jumpToMainPageOnError = true, - onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)), + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) + .andThen { _, _ -> + coderHeaderPage.isBusy.update { false } + }, onTokenRefreshed = ::onTokenRefreshed ) ) @@ -399,9 +403,9 @@ class CoderRemoteProvider( "Error encountered while handling Coder URI", textError ?: "" ) + coderHeaderPage.isBusy.update { false } context.envPageManager.showPluginEnvironmentsPage() } finally { - coderHeaderPage.isBusy.update { false } firstRun = false } } diff --git a/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt b/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt index 0af9def5..54efb0a7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt +++ b/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt @@ -12,6 +12,7 @@ fun interface SuspendBiConsumer { * Chains this consumer with [next], returning a new [SuspendBiConsumer] * that executes both in sequence. */ + fun andThen(next: SuspendBiConsumer): SuspendBiConsumer = SuspendBiConsumer { first, second -> this.accept(first, second) next.accept(first, second)