Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 2 additions & 10 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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.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
Expand All @@ -18,6 +21,7 @@ import java.util.UUID

@Suppress("UnstableApiUsage")
data class CoderToolboxContext(
val oauthManager: PluginAuthManager<CoderAccount, CoderOAuthCfg>,
val ui: ToolboxUi,
val envPageManager: EnvironmentUiPageManager,
val envStateColorPalette: EnvironmentStateColorPalette,
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,6 +31,11 @@ class CoderToolboxExtension : RemoteDevExtension {
val logger = serviceLocator.getService(Logger::class.java)
return CoderRemoteProvider(
CoderToolboxContext(
serviceLocator.getAuthManager(
CoderAccount::class.java,
"Coder OAuth2 Manager",
CoderOAuthManager()
),
serviceLocator.getService<ToolboxUi>(),
serviceLocator.getService<EnvironmentUiPageManager>(),
serviceLocator.getService<EnvironmentStateColorPalette>(),
Expand Down
24 changes: 24 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,24 @@
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,
}
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: String,
@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
)
5 changes: 5 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,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
16 changes: 16 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,16 @@
package com.coder.toolbox.oauth

import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

interface CoderAuthorizationApi {
@GET(".well-known/oauth-authorization-server")
suspend fun discoveryMetadata(): Response<AuthorizationServer>

@POST("oauth2/register")
suspend fun registerClient(
@Body request: ClientRegistrationRequest
): Response<ClientRegistrationResponse>
}
92 changes: 92 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,92 @@
package com.coder.toolbox.oauth

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 : PluginAuthInterface<CoderAccount, CoderOAuthCfg> {
private lateinit var refreshConf: CoderRefreshConfig

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: CoderOAuthCfg): AuthConfiguration {
val codeVerifier = PKCEGenerator.generateCodeVerifier()
val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier)
refreshConf = loginConfiguration.toRefreshConf()

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 = refreshConf.refreshUrl
override val parameters: Map<String, String> = mapOf(
"grant_type" to "refresh_token",
"client_id" to refreshConf.clientId,
"client_secret" to refreshConf.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,
)

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
)
42 changes: 42 additions & 0 deletions src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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) {
Expand Down
Loading
Loading