Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
8bfee5e
build: simplify install folder resolution
fioan89 Oct 9, 2025
1a3415b
impl: setup auth manager with auth and token endpoints
fioan89 Oct 9, 2025
7685feb
impl: retrieve supported response type and the dynamic client registr…
fioan89 Oct 13, 2025
52648a0
impl: models for dynamic client registration
fioan89 Oct 13, 2025
72a902f
impl: pixy secure code generator
fioan89 Oct 13, 2025
0e03b03
impl: retrofit API for endpoint discovery and dynamic client registra…
fioan89 Oct 13, 2025
79ba4cb
impl: factory method for the auth manager
fioan89 Oct 13, 2025
59d2abd
impl: improve auth manager config
fioan89 Oct 13, 2025
decb082
refactor: simplify OAuth manager architecture and improve dependency …
fioan89 Oct 14, 2025
d432a76
fix: inject mocked PluginAuthManager into UTs
fioan89 Oct 14, 2025
2a28cee
impl: handle the redirect URI
fioan89 Oct 14, 2025
6462f14
fix: wrong client app registration endpoint
fioan89 Oct 16, 2025
0e46da0
impl: simple way of triggering the OAuth flow.
fioan89 Oct 16, 2025
bc09057
Merge branch 'main' into impl-support-for-oauth
fioan89 Oct 20, 2025
8e6c5a2
Merge branch 'main' into impl-support-for-oauth
fioan89 Oct 28, 2025
17b859d
impl: add config to enforce auth via API token
fioan89 Oct 29, 2025
acf4d2c
Merge branch 'main' into impl-support-for-oauth
fioan89 Feb 3, 2026
7bd8035
Merge branch 'main' into impl-support-for-oauth
fioan89 Feb 3, 2026
408cdc4
impl: resolve the account
fioan89 Feb 3, 2026
dca1543
chore: fix UTs
fioan89 Feb 4, 2026
836f45a
impl: rework the first login screen and discover if oauth2 is supported
fioan89 Feb 4, 2026
6aeaf68
impl: prefer client_secret_post as token auth method if available
fioan89 Feb 4, 2026
eaaa88b
fix: missing client secret from authorization request
fioan89 Feb 4, 2026
033104f
fix: prefer client_secret_basic auth method
fioan89 Feb 5, 2026
4aac78e
impl: support for client_secret_basic and client_secret_post for toke…
fioan89 Feb 5, 2026
c333c65
impl: implement our own OAuth2 client (1)
fioan89 Feb 9, 2026
63a81bc
impl: implement our own OAuth2 client (2)
fioan89 Feb 9, 2026
33e076d
impl: implement our own OAuth2 client (3)
fioan89 Feb 9, 2026
35f7624
fix: code challenge was sent twice to the auth endpoint
fioan89 Feb 9, 2026
ba356bc
fix: include state for cross-checking later when the auth code is ret…
fioan89 Feb 9, 2026
51cd195
fix: short circuit the URI handler when handling oauth callbacks
fioan89 Feb 9, 2026
6d26d63
fix: short circuit the URI handler when handling oauth callbacks (2)
fioan89 Feb 10, 2026
3d42eb0
impl: implement our own OAuth2 client (4)
fioan89 Feb 10, 2026
ea747f1
impl: support for token refresh (1)
fioan89 Feb 12, 2026
b989b36
chore: rename context class
fioan89 Feb 16, 2026
b01f0c8
fix: broken UTs
fioan89 Feb 16, 2026
08842cf
chore: dependency declared twice in the build system
fioan89 Feb 17, 2026
1a1d9dc
fix: dependingon the JDK vendor and version zt-exec can raise IOExcep…
fioan89 Feb 17, 2026
dc8e589
impl: persist refresh token between Toolbox restarts (1)
fioan89 Feb 18, 2026
50917ea
impl: persist refresh token between Toolbox restarts (2)
fioan89 Feb 18, 2026
9a78aab
impl: persist refresh token between Toolbox restarts (3)
fioan89 Feb 18, 2026
3a624ba
impl: support logging out and logging back in via OAuth2
fioan89 Feb 25, 2026
24424b9
fix: properly close the setup wizard if connection is successful
fioan89 Feb 27, 2026
ab19d78
fix: mark the header bar as busy
fioan89 Feb 27, 2026
e23f0ce
Merge branch 'main' into impl-support-for-oauth
fioan89 Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -204,21 +204,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
}
Expand Down
221 changes: 179 additions & 42 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Large diffs are not rendered by default.

14 changes: 2 additions & 12 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
13 changes: 1 addition & 12 deletions src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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/")
Expand Down
34 changes: 34 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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,
@field:Json(name = "registration_endpoint") val registrationEndpoint: String,
@property:Json(name = "response_types_supported") val supportedResponseTypes: List<String>,
@property:Json(name = "token_endpoint_auth_methods_supported") val authMethodForTokenEndpoint: List<TokenEndpointAuthMethod>,
)

enum class TokenEndpointAuthMethod {
@Json(name = "none")
NONE,

@Json(name = "client_secret_post")
CLIENT_SECRET_POST,

@Json(name = "client_secret_basic")
CLIENT_SECRET_BASIC,
}

fun List<TokenEndpointAuthMethod>.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

}
}
Original file line number Diff line number Diff line change
@@ -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<String>,
@field:Json(name = "grant_types") val grantTypes: List<String>,
@field:Json(name = "response_types") val responseTypes: List<String>,
@field:Json(name = "scope") val scope: String,
@field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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<String>,
@field:Json(name = "grant_types") val grantTypes: List<String>,
@field:Json(name = "response_types") val responseTypes: List<String>,
@field:Json(name = "scope") val scope: 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,
@field:Json(name = "registration_access_token") val registrationAccessToken: String
)
14 changes: 14 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +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 clientSecret: String? = null
) : Account
39 changes: 39 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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")
suspend fun discoveryMetadata(): Response<AuthorizationServer>

@POST("oauth2/register")
suspend fun registerClient(
@Body request: ClientRegistrationRequest
): Response<ClientRegistrationResponse>

@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<OAuthTokenResponse>

@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<OAuthTokenResponse>
}
139 changes: 139 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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.Credentials
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.URL

class CoderOAuthManager : PluginAuthInterface<CoderAccount, CoderOAuthCfg> {
private val moshi = Moshi.Builder().add(UUIDConverter()).build()

override fun serialize(account: CoderAccount): String {
val adapter = moshi.adapter(CoderAccount::class.java)
return adapter.toJson(account)
}

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 {
val user = fetchUser(token, config.baseUrl.toURL())
return CoderAccount(
user.id.toString(),
user.username,
config.baseUrl,
config.tokenUrl,
config.tokenParams["client_id"]!!,
config.tokenParams["client_secret"]
)
}

override suspend fun updateAccount(
token: OAuthToken,
account: CoderAccount
): CoderAccount {
val user = fetchUser(token, account.baseUrl.toURL())
return CoderAccount(
user.id.toString(),
user.username,
account.baseUrl,
account.refreshUrl,
account.clientId,
account.clientSecret
)
}

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)

return AuthConfiguration(
authParams = mapOf(
"client_id" to loginConfiguration.clientId,
"response_type" to "code",
"code_challenge" to codeChallenge
),
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 = if (loginConfiguration.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_BASIC) {
Credentials.basic(loginConfiguration.clientId, loginConfiguration.clientSecret)
} else {
null
}
)
}

override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration {
return object : RefreshConfiguration {
override val refreshUrl: String = account.baseUrl
override val parameters: Map<String, String> = 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
}
}
}

data class CoderOAuthCfg(
val baseUrl: String,
val authUrl: String,
val tokenUrl: String,
val clientId: String,
val clientSecret: String,
val tokenAuthMethod: TokenEndpointAuthMethod,
val redirectUri: String
)
15 changes: 15 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading