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/13] 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 cdfc5e8..f466871 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/13] 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 0000000..bedf760 --- /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 0000000..3b3d787 --- /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 0000000..9e667e0 --- /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 7e2a8e3..2f50ab5 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 af1b4ef..5783dee 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/13] 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 bedf760..4248ef1 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/13] 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 0000000..d0854d1 --- /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 0000000..e0d932c --- /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/13] 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 0000000..fdbefd7 --- /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/13] 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 0000000..ecd9ca9 --- /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/13] 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 ac3cbcc..2703719 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 5cfcd11..a5586b0 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/13] 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 9e667e0..4739068 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/13] 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 2703719..e56b500 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 a5586b0..ea37e4b 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 4739068..2488277 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/13] 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 7f5c831..8ecf59e 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 49314c5..9db0032 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 4a9ef88..8a1c300 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/13] 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 ed4854c..40ade7a 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/13] 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 e0d932c..4ab5d19 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 ecd9ca9..8c7e3fe 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/13] 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 27e53f9..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 {