diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d700b16..4b9e3f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ - `Binary destination` can now point directly to an executable, used as-is; otherwise it is treated as a base directory as before +- support for OAuth2 ### Removed - support for enabling or disabling the fallback to data dir. CLI resolution will always fall back on data dir if binary destination is not configured. +- redesigned the Settings page, all the options are now grouped in a couple of sections for easy navigation ## 0.8.6 - 2026-03-05 diff --git a/build.gradle.kts b/build.gradle.kts index 5480e6f2..83b11cf4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,17 +1,6 @@ -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter import com.github.jk1.license.render.JsonReportRenderer -import com.jetbrains.plugin.structure.toolbox.ToolboxMeta -import com.jetbrains.plugin.structure.toolbox.ToolboxPluginDescriptor -import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory -import org.jetbrains.intellij.pluginRepository.model.ProductFamily -import org.jetbrains.kotlin.com.intellij.openapi.util.SystemInfoRt import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.nio.file.Path -import kotlin.io.path.createDirectories -import kotlin.io.path.div -import kotlin.io.path.writeText plugins { alias(libs.plugins.kotlin) @@ -23,25 +12,14 @@ plugins { alias(libs.plugins.changelog) alias(libs.plugins.gettext) alias(libs.plugins.detekt) + id("toolbox-convention") } - repositories { mavenCentral() maven("https://packages.jetbrains.team/maven/p/tbx/toolbox-api") } -buildscript { - repositories { - mavenCentral() - } - - dependencies { - classpath(libs.marketplace.client) - classpath(libs.plugin.structure) - } -} - jvmWrapper { unixJvmInstallDir = "jvm" winJvmInstallDir = "jvm" @@ -64,37 +42,23 @@ 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( - id = properties("group"), - - version = properties("version"), - meta = ExtensionJsonMeta( - name = "Coder", - description = "Connects your JetBrains IDE to Coder workspaces", - vendor = "coder", - url = "https://github.com/coder/coder-jetbrains-toolbox", - ) -) - -val extensionJsonFile = layout.buildDirectory.file("generated/extension.json") -val extensionJson by tasks.registering { - inputs.property("extension", extension.toString()) - - outputs.file(extensionJsonFile) - doLast { - generateExtensionJson(extension, extensionJsonFile.get().asFile.toPath()) - } +toolbox { + pluginName.set("Coder") + pluginDescription.set("Connects your JetBrains IDE to Coder workspaces") + pluginVendor.set("coder") + pluginUrl.set("https://github.com/coder/coder-jetbrains-toolbox") + apiVersion.set(libs.versions.toolbox.plugin.api) } changelog { - version.set(extension.version) + version.set(project.version.toString()) groups.set(emptyList()) title.set("Coder Toolbox Plugin Changelog") } @@ -132,165 +96,7 @@ tasks.withType().configureEach { ignoreFailures = false } - -tasks.jar { - archiveBaseName.set(extension.id) - dependsOn(extensionJson) - from(extensionJson.get().outputs) -} - -val copyPlugin by tasks.creating(Sync::class.java) { - dependsOn(tasks.jar) - dependsOn(tasks.getByName("generateLicenseReport")) - - fromCompileDependencies() - into(getPluginInstallDir()) -} - -fun CopySpec.fromCompileDependencies() { - from(tasks.jar) - from(extensionJson.get().outputs.files) - from("src/main/resources") { - include("dependencies.json") - } - from("src/main/resources") { - include("icon.svg") - include("pluginIcon.svg") - } - - // Copy dependencies, excluding those provided by Toolbox. - from( - configurations.compileClasspath.map { configuration -> - configuration.files.filterNot { file -> - listOf( - "kotlin", - "remote-dev-api", - "core-api", - "ui-api", - "annotations", - "localization-api", - "slf4j-api" - ).any { file.name.contains(it) } - } - }, - ) -} - -/** - * Useful when doing manual local install. - */ -val pluginPrettyZip by tasks.creating(Zip::class) { - archiveBaseName.set(properties("name")) - dependsOn(tasks.jar) - dependsOn(tasks.getByName("generateLicenseReport")) - - fromCompileDependencies() - into(extension.id) // folder like com.coder.toolbox -} - -val pluginZip by tasks.creating(Zip::class) { - dependsOn(tasks.jar) - dependsOn(tasks.getByName("generateLicenseReport")) - - fromCompileDependencies() - archiveBaseName.set(extension.id) -} - -tasks.register("cleanAll", Delete::class.java) { - dependsOn(tasks.clean) - delete(getPluginInstallDir()) - delete() -} - -private fun getPluginInstallDir(): Path { - val userHome = System.getProperty("user.home").let { Path.of(it) } - val toolboxCachesDir = 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" - - return pluginsDir / extension.id -} - -val publishPlugin by tasks.registering { - dependsOn(pluginZip) - - doLast { - val pluginMarketplaceToken: String = if (System.getenv("JETBRAINS_MARKETPLACE_PUBLISH_TOKEN").isNullOrBlank()) { - error("Env. variable `JETBRAINS_MARKETPLACE_PUBLISH_TOKEN` does not exist. Please set the env. variable to a token obtained from the marketplace.") - } else { - System.getenv("JETBRAINS_MARKETPLACE_PUBLISH_TOKEN") - } - - println("Plugin Marketplace Token: ${pluginMarketplaceToken.take(5)}*****") - - val instance = PluginRepositoryFactory.create( - "https://plugins.jetbrains.com", - pluginMarketplaceToken - ) - - // !!! subsequent updates !!! - instance.uploader.uploadUpdateByXmlIdAndFamily( - extension.id, // do not change - ProductFamily.TOOLBOX, // do not change - pluginZip.outputs.files.singleFile, // do not change - null, // do not change. Channels will be available later - "Bug fixes and improvements", - false - ) - } -} - -fun properties(key: String) = project.findProperty(key).toString() - gettext { potFile = project.layout.projectDirectory.file("src/main/resources/localization/defaultMessages.pot") keywords = listOf("ptrc:1c,2", "ptrl") } - -// region will be moved to the gradle plugin late -data class ExtensionJsonMeta( - val name: String, - val description: String, - val vendor: String, - val url: String?, -) - -data class ExtensionJson( - val id: String, - val version: String, - val meta: ExtensionJsonMeta, -) - -fun generateExtensionJson(extensionJson: ExtensionJson, destinationFile: Path) { - val descriptor = ToolboxPluginDescriptor( - id = extensionJson.id, - version = extensionJson.version, - apiVersion = libs.versions.toolbox.plugin.api.get(), - meta = ToolboxMeta( - name = extensionJson.meta.name, - description = extensionJson.meta.description, - vendor = extensionJson.meta.vendor, - url = extensionJson.meta.url, - ) - ) - destinationFile.parent.createDirectories() - destinationFile.writeText( - jacksonMapperBuilder() - .enable(SerializationFeature.INDENT_OUTPUT) - .build() - .writeValueAsString(descriptor) - ) -} -// endregion diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..aea99396 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/tbx/toolbox-api") +} + +dependencies { + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") + implementation("org.jetbrains.intellij.plugins:structure-toolbox:3.321") + implementation("org.jetbrains.intellij:plugin-repository-rest-client:2.0.50") +} diff --git a/buildSrc/src/main/kotlin/CleanAllTask.kt b/buildSrc/src/main/kotlin/CleanAllTask.kt new file mode 100644 index 00000000..ce8ae19a --- /dev/null +++ b/buildSrc/src/main/kotlin/CleanAllTask.kt @@ -0,0 +1,13 @@ +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.Input + +abstract class CleanAllTask : Delete() { + + @get:Input + abstract val extensionId: Property + + init { + delete(extensionId.map { getPluginInstallDir(it).toFile() }) + } +} diff --git a/buildSrc/src/main/kotlin/ExtensionJson.kt b/buildSrc/src/main/kotlin/ExtensionJson.kt new file mode 100644 index 00000000..2a98f177 --- /dev/null +++ b/buildSrc/src/main/kotlin/ExtensionJson.kt @@ -0,0 +1,41 @@ +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder +import com.jetbrains.plugin.structure.toolbox.ToolboxMeta +import com.jetbrains.plugin.structure.toolbox.ToolboxPluginDescriptor +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +data class ExtensionJsonMeta( + val name: String, + val description: String, + val vendor: String, + val url: String?, +) + +data class ExtensionJson( + val id: String, + val version: String, + val meta: ExtensionJsonMeta, +) + +fun generateExtensionJson(extensionJson: ExtensionJson, apiVersion: String, destinationFile: Path) { + val descriptor = ToolboxPluginDescriptor( + id = extensionJson.id, + version = extensionJson.version, + apiVersion = apiVersion, + meta = ToolboxMeta( + name = extensionJson.meta.name, + description = extensionJson.meta.description, + vendor = extensionJson.meta.vendor, + url = extensionJson.meta.url, + ) + ) + destinationFile.parent.createDirectories() + destinationFile.writeText( + jacksonMapperBuilder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build() + .writeValueAsString(descriptor) + ) +} diff --git a/buildSrc/src/main/kotlin/GenerateExtensionJsonTask.kt b/buildSrc/src/main/kotlin/GenerateExtensionJsonTask.kt new file mode 100644 index 00000000..eedc89a7 --- /dev/null +++ b/buildSrc/src/main/kotlin/GenerateExtensionJsonTask.kt @@ -0,0 +1,62 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +abstract class GenerateExtensionJsonTask : DefaultTask() { + + @get:Input + abstract val extensionId: Property + + @get:Input + abstract val extensionVersion: Property + + @get:Input + abstract val apiVersion: Property + + @get:Input + abstract val metaName: Property + + @get:Input + abstract val metaDescription: Property + + @get:Input + abstract val metaVendor: Property + + @get:Input + @get:Optional + abstract val metaUrl: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + /** + * Convenience method to set all extension properties at once. + */ + fun fromExtension(ext: ExtensionJson) { + extensionId.set(ext.id) + extensionVersion.set(ext.version) + metaName.set(ext.meta.name) + metaDescription.set(ext.meta.description) + metaVendor.set(ext.meta.vendor) + ext.meta.url?.let { metaUrl.set(it) } + } + + @TaskAction + fun generate() { + val ext = ExtensionJson( + id = extensionId.get(), + version = extensionVersion.get(), + meta = ExtensionJsonMeta( + name = metaName.get(), + description = metaDescription.get(), + vendor = metaVendor.get(), + url = metaUrl.orNull, + ) + ) + generateExtensionJson(ext, apiVersion.get(), outputFile.get().asFile.toPath()) + } +} diff --git a/buildSrc/src/main/kotlin/InstallToolboxPluginTask.kt b/buildSrc/src/main/kotlin/InstallToolboxPluginTask.kt new file mode 100644 index 00000000..e91534ba --- /dev/null +++ b/buildSrc/src/main/kotlin/InstallToolboxPluginTask.kt @@ -0,0 +1,35 @@ +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Sync + +abstract class InstallToolboxPluginTask : Sync() { + + @get:InputFiles + abstract val jarFiles: ConfigurableFileCollection + + @get:InputFiles + abstract val extensionJsonFiles: ConfigurableFileCollection + + @get:InputFiles + abstract val runtimeDependencies: ConfigurableFileCollection + + @get:InputDirectory + abstract val resourcesDir: DirectoryProperty + + @get:Input + abstract val extensionId: Property + + init { + from(jarFiles) + from(extensionJsonFiles) + from(resourcesDir) { + include("dependencies.json", "icon.svg", "pluginIcon.svg") + } + from(runtimeDependencies) + into(extensionId.map { getPluginInstallDir(it).toFile() }) + } +} diff --git a/buildSrc/src/main/kotlin/OS.kt b/buildSrc/src/main/kotlin/OS.kt new file mode 100644 index 00000000..562b8e05 --- /dev/null +++ b/buildSrc/src/main/kotlin/OS.kt @@ -0,0 +1,18 @@ +enum class OS { + WINDOWS, + LINUX, + MAC, + OTHER; + + companion object { + fun current(): OS? = from(System.getProperty("os.name")) + + private fun from(os: String?): OS? = when { + os.isNullOrBlank() -> OTHER + os.contains("win", true) -> WINDOWS + os.contains("nix", true) || os.contains("nux", true) || os.contains("aix", true) -> LINUX + os.contains("mac", true) || os.contains("darwin", true) -> MAC + else -> OTHER + } + } +} diff --git a/buildSrc/src/main/kotlin/PluginUtils.kt b/buildSrc/src/main/kotlin/PluginUtils.kt new file mode 100644 index 00000000..49ae7891 --- /dev/null +++ b/buildSrc/src/main/kotlin/PluginUtils.kt @@ -0,0 +1,73 @@ +import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory +import org.jetbrains.intellij.pluginRepository.model.ProductFamily +import java.io.File +import java.nio.file.Path +import kotlin.io.path.div + +/** + * Resolves the Toolbox plugin install directory for the current OS. + */ +fun getPluginInstallDir(extensionId: String): Path { + val userHome = System.getProperty("user.home").let { Path.of(it) } + val pluginsDir = when (OS.current()) { + OS.WINDOWS -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") + // currently this is the location that TBA uses on Linux + OS.LINUX -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") + OS.MAC -> userHome / "Library" / "Caches" + else -> error("Unknown os") + } / "JetBrains" / "Toolbox" / "plugins" + + return pluginsDir / extensionId +} + +/** + * Dependency name fragments provided by Toolbox at runtime. + * These should be excluded when packaging plugin dependencies. + */ +val TOOLBOX_PROVIDED_DEPENDENCIES = listOf( + "kotlin", + "remote-dev-api", + "core-api", + "ui-api", + "annotations", + "localization-api", + "slf4j-api" +) + +/** + * Filters out dependencies that are provided by Toolbox at runtime. + */ +fun filterToolboxProvidedDependencies(files: Set): List = + files.filterNot { file -> + TOOLBOX_PROVIDED_DEPENDENCIES.any { file.name.contains(it) } + } + +/** + * Publishes the plugin to the JetBrains Marketplace. + */ +fun publishToMarketplace(extensionId: String, pluginFile: File) { + val token = System.getenv("JETBRAINS_MARKETPLACE_PUBLISH_TOKEN") + if (token.isNullOrBlank()) { + error( + "Env. variable `JETBRAINS_MARKETPLACE_PUBLISH_TOKEN` does not exist. " + + "Please set the env. variable to a token obtained from the marketplace." + ) + } + + println("Plugin Marketplace Token: ${token.take(5)}*****") + + val instance = PluginRepositoryFactory.create( + "https://plugins.jetbrains.com", + token + ) + + // !!! subsequent updates !!! + instance.uploader.uploadUpdateByXmlIdAndFamily( + extensionId, // do not change + ProductFamily.TOOLBOX, // do not change + pluginFile, // do not change + null, // do not change. Channels will be available later + "Bug fixes and improvements", + false + ) +} diff --git a/buildSrc/src/main/kotlin/PublishToolboxPluginTask.kt b/buildSrc/src/main/kotlin/PublishToolboxPluginTask.kt new file mode 100644 index 00000000..b0a39e97 --- /dev/null +++ b/buildSrc/src/main/kotlin/PublishToolboxPluginTask.kt @@ -0,0 +1,20 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.TaskAction + +abstract class PublishToolboxPluginTask : DefaultTask() { + + @get:Input + abstract val extensionId: Property + + @get:InputFile + abstract val pluginFile: RegularFileProperty + + @TaskAction + fun publish() { + publishToMarketplace(extensionId.get(), pluginFile.get().asFile) + } +} diff --git a/buildSrc/src/main/kotlin/ToolboxExtension.kt b/buildSrc/src/main/kotlin/ToolboxExtension.kt new file mode 100644 index 00000000..7335ae20 --- /dev/null +++ b/buildSrc/src/main/kotlin/ToolboxExtension.kt @@ -0,0 +1,9 @@ +import org.gradle.api.provider.Property + +abstract class ToolboxExtension { + abstract val pluginName: Property + abstract val pluginDescription: Property + abstract val pluginVendor: Property + abstract val pluginUrl: Property + abstract val apiVersion: Property +} diff --git a/buildSrc/src/main/kotlin/ToolboxPluginZipTask.kt b/buildSrc/src/main/kotlin/ToolboxPluginZipTask.kt new file mode 100644 index 00000000..a4288c4c --- /dev/null +++ b/buildSrc/src/main/kotlin/ToolboxPluginZipTask.kt @@ -0,0 +1,29 @@ +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.bundling.Zip + +abstract class ToolboxPluginZipTask : Zip() { + + @get:InputFiles + abstract val jarFiles: ConfigurableFileCollection + + @get:InputFiles + abstract val extensionJsonFiles: ConfigurableFileCollection + + @get:InputFiles + abstract val runtimeDependencies: ConfigurableFileCollection + + @get:InputDirectory + abstract val resourcesDir: DirectoryProperty + + init { + from(jarFiles) + from(extensionJsonFiles) + from(resourcesDir) { + include("dependencies.json", "icon.svg", "pluginIcon.svg") + } + from(runtimeDependencies) + } +} diff --git a/buildSrc/src/main/kotlin/toolbox-convention.gradle.kts b/buildSrc/src/main/kotlin/toolbox-convention.gradle.kts new file mode 100644 index 00000000..061255ce --- /dev/null +++ b/buildSrc/src/main/kotlin/toolbox-convention.gradle.kts @@ -0,0 +1,64 @@ +val toolbox = extensions.create("toolbox") + +val extensionJson by tasks.registering(GenerateExtensionJsonTask::class) { + extensionId.set(project.group.toString()) + extensionVersion.set(project.version.toString()) + apiVersion.set(toolbox.apiVersion) + metaName.set(toolbox.pluginName) + metaDescription.set(toolbox.pluginDescription) + metaVendor.set(toolbox.pluginVendor) + metaUrl.set(toolbox.pluginUrl) + outputFile.set(layout.buildDirectory.file("generated/extension.json")) +} + +tasks.named("jar") { + archiveBaseName.set(project.group.toString()) + dependsOn(extensionJson) + from(extensionJson.get().outputs) +} + +val filteredDependencies = configurations.named("compileClasspath").map { + filterToolboxProvidedDependencies(it.files) +} + +val copyPlugin by tasks.registering(InstallToolboxPluginTask::class) { + dependsOn(tasks.named("generateLicenseReport")) + jarFiles.from(tasks.named("jar")) + extensionJsonFiles.from(extensionJson.flatMap { it.outputFile }) + runtimeDependencies.from(filteredDependencies) + resourcesDir.set(layout.projectDirectory.dir("src/main/resources")) + extensionId.set(project.group.toString()) +} + +/** + * Useful when doing manual local installation. + */ +val pluginPrettyZip by tasks.registering(ToolboxPluginZipTask::class) { + archiveBaseName.set(project.name) + dependsOn(tasks.named("generateLicenseReport")) + jarFiles.from(tasks.named("jar")) + extensionJsonFiles.from(extensionJson.flatMap { it.outputFile }) + runtimeDependencies.from(filteredDependencies) + resourcesDir.set(layout.projectDirectory.dir("src/main/resources")) + into(project.group.toString()) // folder like com.coder.toolbox +} + +val pluginZip by tasks.registering(ToolboxPluginZipTask::class) { + dependsOn(tasks.named("generateLicenseReport")) + jarFiles.from(tasks.named("jar")) + extensionJsonFiles.from(extensionJson.flatMap { it.outputFile }) + runtimeDependencies.from(filteredDependencies) + resourcesDir.set(layout.projectDirectory.dir("src/main/resources")) + archiveBaseName.set(project.group.toString()) +} + +val cleanAll by tasks.registering(CleanAllTask::class) { + dependsOn(tasks.named("clean")) + extensionId.set(project.group.toString()) +} + +val publishPlugin by tasks.registering(PublishToolboxPluginTask::class) { + dependsOn(pluginZip) + extensionId.set(project.group.toString()) + pluginFile.set(pluginZip.flatMap { it.archiveFile }) +} diff --git a/gradle.properties b/gradle.properties index 9ea18a21..57ccec55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.7 +version=0.9.0 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4fe6155b..d9dae65b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -toolbox-plugin-api = "1.3.47293" -kotlin = "2.1.20" +toolbox-plugin-api = "1.10.76281" +kotlin = "2.3.10" coroutines = "1.10.2" -serialization = "1.8.1" +serialization = "1.9.0" okhttp = "4.12.0" dependency-license-report = "3.1.1" marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.20-2.0.1" +ksp = "2.3.6" retrofit = "3.0.0" changelog = "2.5.0" gettext = "0.7.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138..dbc3ce4a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 172ab4f5..e7c495a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,17 @@ rootProject.name = "coder-toolbox" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/tbx/toolbox-api") + } +} + +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/tbx/toolbox-api") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 2eb5867e..af1e2789 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -3,6 +3,8 @@ 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.OAuth2Service +import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException @@ -17,15 +19,16 @@ 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.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState +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 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 @@ -48,7 +51,6 @@ import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select 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 @@ -56,6 +58,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownF private val POLL_INTERVAL = 5.seconds private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" +private const val FAILED_TO_HANDLE_OAUTH2_TITLE = "Failed to handle OAuth2 request" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -79,7 +82,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 -> @@ -265,7 +268,6 @@ class CoderRemoteProvider( lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() context.logger.info("Coder plugin is now closed") } @@ -274,6 +276,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") @@ -341,49 +344,53 @@ 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 + } + val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario 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!!) + coderHeaderPage.isBusy.update { false } } else { - CoderCliSetupContext.apply { + // 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 } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, - initialAutoSetup = true, - jumpToMainPageOnError = true, - connectSynchronously = true, - onConnect = ::onConnect - ).apply { - beforeShow() - } + CoderSetupWizardState.goToStep(WizardStep.CONNECT) + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) + .andThen { _, _ -> + coderHeaderPage.isBusy.update { false } + }, + 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()) { @@ -394,14 +401,56 @@ class CoderRemoteProvider( "Error encountered while handling Coder URI", textError ?: "" ) + coderHeaderPage.isBusy.update { false } context.envPageManager.showPluginEnvironmentsPage() } finally { - coderHeaderPage.isBusy.update { false } - isHandlingUri.set(false) firstRun = false } } + private suspend fun handleOAuthUri(uri: URI) { + val params = uri.toQueryParameters() + + // RFC 6749 §4.1.2.1 (also covers RFC 7636 §4.4.1 PKCE errors): the authorization + // server redirects back with `error` when authorization fails (e.g. access_denied, + // invalid_request, unsupported_response_type, server_error, ...). + val error = params["error"] + if (error != null) { + val description = params["error_description"]?.let { " - $it" } ?: "" + return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 authorization error: $error$description" + ) + } + + params["state"]?.takeIf { it == CoderSetupWizardContext.oauthSession?.state } + ?: return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "Server responded back with an invalid state that does not match the initial authorization state sent to the server" + ) + + val code = params["code"] ?: return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 server did not respond back with an access token" + ) + + exchangeOAuthCodeForToken(code, CoderSetupWizardContext.oauthSession!!) + } + + private suspend fun exchangeOAuthCodeForToken(code: String, oauthSessionContext: CoderOAuthSessionContext) { + try { + context.logger.info("Handling OAuth callback...") + + val tokenResponse = OAuth2Service(context).exchangeCode(oauthSessionContext, code) + oauthSessionContext.tokenResponse = tokenResponse + + CoderSetupWizardState.goToStep(WizardStep.CONNECT) + + } 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()) { @@ -434,6 +483,7 @@ class CoderRemoteProvider( context, url, token, + null, PluginManager.pluginInfo.version, ).apply { initializeSession() } val newCli = CoderCLIManager(context, url).apply { @@ -462,24 +512,40 @@ 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. if (shouldDoAutoSetup()) { try { - CoderCliSetupContext.apply { + val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString()) + CoderSetupWizardContext.apply { url = context.deploymentUrl - token = context.secrets.tokenFor(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 + ) + ) + } } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderSetupWizardState.goToStep(WizardStep.CONNECT) return CoderCliSetupWizardPage( context, settingsPage, visibilityState, initialAutoSetup = true, jumpToMainPageOnError = false, - onConnect = ::onConnect + onConnect = onConnect, + onTokenRefreshed = ::onTokenRefreshed ) } catch (ex: Exception) { errorBuffer.add(ex) @@ -489,8 +555,15 @@ class CoderRemoteProvider( } // Login flow. + CoderSetupWizardState.goToFirstStep() 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) @@ -508,14 +581,22 @@ 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() || context.secrets.oauthSessionFor(context.deploymentUrl.toString()) != null - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) { + oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) } + context.secrets.storeOAuthFor(url.toString(), oauthSessionCtx) + } + + private val onConnect: SuspendBiConsumer = SuspendBiConsumer { client, cli -> // Store the URL and token for use next time. close() context.settingsStore.updateLastUsedUrl(client.url) if (context.settingsStore.requiresTokenAuth) { - context.secrets.storeTokenFor(client.url, client.token ?: "") + if (client.token != null) { + context.secrets.storeApiTokenFor(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") @@ -538,6 +619,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/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 6a8617fe..eba6b236 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 @@ -125,15 +122,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/oauth/AuthorizationServer.kt b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt new file mode 100644 index 00000000..2077e137 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt @@ -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, + @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, +} + +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 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..5ecb0e2b --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -0,0 +1,47 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi + +/** + * 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: 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 +) + +/** + * RFC 7591 Section 3.2.2 — Client Registration Error Response. + */ +@JsonClass(generateAdapter = true) +data class ClientRegistrationErrorResponse( + @field:Json(name = "error") val error: String, + @field:Json(name = "error_description") val errorDescription: String? = null +) { + fun toMessage(): String = if (errorDescription.isNullOrBlank()) error else "$error - $errorDescription" + + companion object { + private val adapter = Moshi.Builder().build().adapter(ClientRegistrationErrorResponse::class.java) + + fun fromJson(json: String): ClientRegistrationErrorResponse? = try { + adapter.fromJson(json) + } catch (_: Exception) { + null + } + } +} + +class ClientRegistrationException(message: String) : Exception(message) 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..402b4a9b --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt @@ -0,0 +1,39 @@ +package com.coder.toolbox.oauth + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.HeaderMap +import retrofit2.http.POST +import retrofit2.http.Url + +interface CoderAuthorizationApi { + @GET + suspend fun discoverMetadata( + @Url url: String + ): Response + + @POST + suspend fun registerClient( + @Url url: String, + @Body request: ClientRegistrationRequest + ): Response + + @POST + @FormUrlEncoded + suspend fun exchangeCode( + @Url url: String, + @HeaderMap headers: Map = emptyMap(), + @FieldMap fields: Map + ): Response + + @POST + @FormUrlEncoded + suspend fun refreshToken( + @Url url: String, + @HeaderMap headers: Map = emptyMap(), + @FieldMap fields: Map + ): Response +} diff --git a/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Service.kt b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Service.kt new file mode 100644 index 00000000..fe899165 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuth2Service.kt @@ -0,0 +1,177 @@ +package com.coder.toolbox.oauth + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory +import com.coder.toolbox.views.state.CoderOAuthSessionContext +import com.squareup.moshi.Moshi +import okhttp3.Credentials +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +private const val DISCOVERY_PATH = ".well-known/oauth-authorization-server" + +class OAuth2Service(private val context: CoderToolboxContext) { + private val service = createAuthorizationService() + + suspend fun discoverMetadata(baseUrl: String): AuthorizationServer? { + val response = service.discoverMetadata("$baseUrl/$DISCOVERY_PATH") + if (response.isSuccessful) { + return response.body() + } + context.logger.info("OAuth discovery failed: ${response.code()} ${response.message()} || ${response.errorBody()}") + return null + } + + suspend fun registerClient(url: String, request: ClientRegistrationRequest): ClientRegistrationResponse { + // TODO - until https://github.com/coder/coder/issues/20370 is delivered + val response = service.registerClient(url, request) + + if (response.isSuccessful) { + return requireNotNull(response.body()) { "Successful response returned null body or client registration metadata" } + } + + val errorBody = response.errorBody()?.string() + val registrationError = errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) } + val errorMessage = if (registrationError != null) { + "OAuth2 client registration failed: ${registrationError.toMessage()}" + } else { + "OAuth2 client registration failed with status ${response.code()}: ${response.message()}" + } + context.logger.error(errorMessage) + throw ClientRegistrationException(errorMessage) + } + + suspend fun exchangeCode( + oauthSessionContext: CoderOAuthSessionContext, + code: String + ): OAuthTokenResponse { + val auth = when (oauthSessionContext.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> ClientAuth.ClientSecretBasic( + oauthSessionContext.clientId, + oauthSessionContext.clientSecret + ) + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> ClientAuth.ClientSecretPost( + oauthSessionContext.clientId, + oauthSessionContext.clientSecret + ) + + else -> ClientAuth.None(oauthSessionContext.clientId) + } + + val response = service.exchangeCode( + url = oauthSessionContext.tokenEndpoint, + headers = auth.headers(), + fields = auth.fields() + mapOf( + "code" to code, + "grant_type" to "authorization_code", + "code_verifier" to oauthSessionContext.tokenCodeVerifier, + "redirect_uri" to "jetbrains://gateway/com.coder.toolbox/auth" + ) + ) + + return handleResponse(response, "exchange code for token") + } + + suspend fun refreshToken(oauthSessionContext: CoderOAuthSessionContext): OAuthTokenResponse { + val refreshToken = oauthSessionContext.tokenResponse?.refreshToken + ?: throw IllegalStateException("No refresh token available") + + val auth = when (oauthSessionContext.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> ClientAuth.ClientSecretBasic( + oauthSessionContext.clientId, + oauthSessionContext.clientSecret + ) + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> ClientAuth.ClientSecretPost( + oauthSessionContext.clientId, + oauthSessionContext.clientSecret + ) + + else -> ClientAuth.None(oauthSessionContext.clientId) + } + + val service = createAuthorizationService() + val response = service.refreshToken( + url = oauthSessionContext.tokenEndpoint, + headers = auth.headers(), + fields = auth.fields() + mapOf( + "grant_type" to "refresh_token", + "refresh_token" to refreshToken + ) + ) + + return handleResponse(response, "refresh OAuth token") + } + + private fun handleResponse( + response: Response, + action: String + ): OAuthTokenResponse { + if (response.isSuccessful) { + return response.body() ?: throw Exception("Failed to $action. Response body is empty.") + } + + val errorBody = response.errorBody()?.string() + val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) } + val errorMessage = if (tokenError != null) { + "Failed to $action: ${tokenError.toMessage()}" + } else { + "Failed to $action. Response code: ${response.code()} ${response.message()}" + } + context.logger.error(errorMessage) + throw Exception(errorMessage) + } + + private fun createAuthorizationService(): CoderAuthorizationApi { + return Retrofit.Builder() + .baseUrl("http://localhost/") // Placeholder, overridden by @Url + .client(CoderHttpClientBuilder.default(context)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + } +} + +private sealed interface ClientAuth { + + fun headers(): Map = emptyMap() + fun fields(): Map = emptyMap() + + data class ClientSecretBasic( + val clientId: String, + val clientSecret: String + ) : ClientAuth { + override fun headers() = mapOf( + "Authorization" to Credentials.basic( + clientId, + clientSecret + ) + ) + } + + data class ClientSecretPost( + val clientId: String, + val clientSecret: String + ) : ClientAuth { + override fun fields() = mapOf( + "client_id" to clientId, + "client_secret" to clientSecret + ) + } + + data class None( + val clientId: String + ) : ClientAuth { + override fun fields() = mapOf( + "client_id" to clientId + ) + } +} 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..5274a0da --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt @@ -0,0 +1,46 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi + +/** + * 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? +) + + +/** + * RFC 6749 §5.2 — Token Endpoint Error Response. + * + * Returned as a JSON body with HTTP 400 (or 401 for invalid_client). + */ +@JsonClass(generateAdapter = true) +data class OAuthTokenErrorResponse( + @field:Json(name = "error") val error: String, + @field:Json(name = "error_description") val errorDescription: String? = null, + @field:Json(name = "error_uri") val errorUri: String? = null, +) { + fun toMessage(): String = buildString { + append(error) + if (!errorDescription.isNullOrBlank()) append(" - $errorDescription") + if (!errorUri.isNullOrBlank()) append(" (see $errorUri)") + } + + companion object { + private val adapter = Moshi.Builder().build().adapter(OAuthTokenErrorResponse::class.java) + + fun fromJson(json: String): OAuthTokenErrorResponse? = try { + adapter.fromJson(json) + } catch (_: Exception) { + null + } + } +} \ No newline at end of file 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 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/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 31255d99..b8acf659 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.OAuth2Service import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.LoggingConverterFactory @@ -21,8 +22,12 @@ 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.OkHttpClient import org.zeroturnaround.exec.ProcessExecutor @@ -42,13 +47,17 @@ open class CoderRestClient( private val context: CoderToolboxContext, val url: URL, val token: String?, + private val oauthContext: CoderOAuthSessionContext? = null, private val pluginVersion: String = "development", + private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> 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 @@ -70,10 +79,11 @@ open class CoderRestClient( val interceptors = buildList { if (context.settingsStore.requiresTokenAuth) { - if (token.isNullOrBlank()) { - throw IllegalStateException("Token is required for $url deployment") + val oauthOrApiToken = oauthContext?.tokenResponse?.accessToken ?: 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)) @@ -342,8 +352,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...") @@ -356,6 +387,13 @@ open class CoderRestClient( } } + private suspend fun refreshToken() { + val newAuthResponse = OAuth2Service(context).refreshToken(oauthContext!!) + this.oauthContext.tokenResponse = newAuthResponse + onTokenRefreshed?.invoke(url, oauthContext) + } + + 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/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/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 74013751..2f8d82a5 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -162,6 +162,12 @@ interface ReadOnlyCoderSettings { */ val networkInfoDir: String + /** + * Indicates whether OAuth2 should be used when available over + * authentication via API tokens. mTLS always takes precedence. + */ + val preferOAuth2IfAvailable: Boolean + /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index a5466b41..f45a6dc3 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,8 +1,16 @@ 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 +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. @@ -14,9 +22,38 @@ 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 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/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 08341326..ab6ea94a 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -82,7 +82,8 @@ class CoderSettingsStore( .resolve("ssh-network-metrics") .normalize() .toString() - + override val preferOAuth2IfAvailable: Boolean + get() = store[PREFER_OAUTH2_IF_AVAILABLE]?.toBooleanStrictOrNull() ?: false override val workspaceViewUrl: String? get() = store[WORKSPACE_VIEW_URL] override val workspaceCreateUrl: String? @@ -257,6 +258,10 @@ class CoderSettingsStore( store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() } + fun updatePreferAuthViaOAuth2(preferAuthViaOAuth2: Boolean) { + store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(getWinAppData(), "coder-toolbox") diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index f73ab9eb..69fea407 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -56,3 +56,4 @@ internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" +internal const val PREFER_OAUTH2_IF_AVAILABLE = "preferOAuth2IfAvailable" diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index 7e2a8e35..aef8c743 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -5,9 +5,16 @@ import com.coder.toolbox.util.WebUrlValidationResult.Valid import java.net.IDN import java.net.URI import java.net.URL +import java.net.URLDecoder 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 +28,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) { @@ -58,7 +68,7 @@ fun URI.toQueryParameters(): Map = (this.query ?: "") }.associate { val parts = it.split("=", limit = 2) if (parts.size == 2) { - parts[0] to parts[1] + parts[0] to URLDecoder.decode(parts[1], Charsets.UTF_8) } else { parts[0] to "" } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index 2c740241..f4391cdb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -3,14 +3,18 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.CoderCliSetupWizardState +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 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( private val context: CoderToolboxContext, @@ -18,11 +22,8 @@ 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) private val settingsAction = Action(context, "Settings") { @@ -35,12 +36,13 @@ class CoderCliSetupWizardPage( context, shouldAutoLogin = shouldAutoSetup, jumpToMainPageOnError = jumpToMainPageOnError, - connectSynchronously = connectSynchronously, visibilityState, refreshWizard = this::displaySteps, - onConnect + 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. @@ -50,12 +52,18 @@ class CoderCliSetupWizardPage( override fun beforeShow() { - displaySteps() + stateCollectJob?.cancel() + stateCollectJob = context.cs.launch { + CoderSetupWizardState.step.collect { step -> + context.logger.info("Wizard step changed to $step") + displaySteps() + } + } errorReporter.flush() } private fun displaySteps() { - when (CoderCliSetupWizardState.currentStep()) { + when (CoderSetupWizardState.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { listOf(deploymentUrlStep.panel) @@ -112,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/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 0a7cd393..934c5eca 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -9,6 +9,7 @@ import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue +import com.jetbrains.toolbox.api.ui.components.SectionField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField @@ -35,7 +36,10 @@ class CoderSettingsPage( CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() - // TODO: Copy over the descriptions, holding until I can test this page. + private val preferOAuth2IfAvailableField = CheckboxField( + context.settingsStore.preferOAuth2IfAvailable, + context.i18n.ptrl("Prefer OAuth2 if available over authentication via API Key") + ) private val binarySourceField = TextField(context.i18n.ptrl("Binary source"), settings.binarySource ?: "", TextType.General) private val binaryDestinationField = @@ -124,25 +128,50 @@ class CoderSettingsPage( private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( - binarySourceField, - enableDownloadsField, - useAppNameField, - binaryDestinationField, - disableSignatureVerificationField, - signatureFallbackStrategyField, - httpLoggingField, - dataDirectoryField, - headerCommandField, - tlsCertPathField, - tlsKeyPathField, - tlsCAPathField, - tlsAlternateHostnameField, - disableAutostartField, - enableSshWildCardConfig, - sshConnectionTimeoutField, - sshLogDirField, - networkInfoDirField, - sshExtraArgs, + SectionField( + "General", + true, + listOf( + useAppNameField, + disableAutostartField, + httpLoggingField, + ) + ), + SectionField( + "Security & Authentication", + false, + listOf( + preferOAuth2IfAvailableField, + headerCommandField, + tlsCertPathField, + tlsKeyPathField, + tlsCAPathField, + tlsAlternateHostnameField, + disableSignatureVerificationField, + signatureFallbackStrategyField, + ) + ), + SectionField( + "CLI", + false, + listOf( + binarySourceField, + binaryDestinationField, + dataDirectoryField, + enableDownloadsField + ) + ), + SectionField( + "SSH", + false, + listOf( + enableSshWildCardConfig, + sshConnectionTimeoutField, + sshLogDirField, + networkInfoDirField, + sshExtraArgs, + ) + ) ) ) @@ -159,6 +188,7 @@ class CoderSettingsPage( updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) updateHeaderCommand(headerCommandField.contentState.value) + updatePreferAuthViaOAuth2(preferOAuth2IfAvailableField.checkedState.value) updateCertPath(tlsCertPathField.contentState.value) updateKeyPath(tlsKeyPathField.contentState.value) updateCAPath(tlsCAPathField.contentState.value) @@ -214,6 +244,10 @@ class CoderSettingsPage( settings.headerCommand ?: "" } + preferOAuth2IfAvailableField.checkedState.update { + settings.preferOAuth2IfAvailable + } + tlsCertPathField.contentState.update { settings.tls.certPath ?: "" } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 3c1c8ef9..efb94b40 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -3,10 +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.OAuth2Service 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.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.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup @@ -18,8 +20,8 @@ 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 java.net.URL private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -30,10 +32,10 @@ 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 @@ -52,14 +54,20 @@ 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"}...") } + // 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() } @@ -67,13 +75,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()) { + if (context.settingsStore.requiresTokenAuth && !CoderSetupWizardContext.hasToken() && !CoderSetupWizardContext.hasOAuthSession()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -87,12 +95,22 @@ class ConnectStep( // 1. Extract the logic into a reusable suspend lambda val connectionLogic: suspend CoroutineScope.() -> Unit = { try { + var oauthSession: CoderOAuthSessionContext? = null + if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable && CoderSetupWizardContext.hasOAuthSession()) { + refreshOAuthToken() + oauthSession = CoderSetupWizardContext.oauthSession!!.copy() + } + + 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) CoderCliSetupContext.token else null, + apiToken, + oauthSession, PluginManager.pluginInfo.version, + onTokenRefreshed ) // allows interleaving with the back/cancel action yield() @@ -109,16 +127,23 @@ class ConnectStep( logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - cli.login(client.token!!) + if (oauthSession != null) { + cli.login(oauthSession.tokenResponse!!.accessToken) + } else { + 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) - - CoderCliSetupContext.reset() - CoderCliSetupWizardState.goToFirstStep() + 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.goToDone() context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { @@ -133,16 +158,17 @@ 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() { + val session = CoderSetupWizardContext.oauthSession ?: return + if (!session.tokenResponse?.accessToken.isNullOrBlank()) return + + logAndReportProgress("Refreshing OAuth token...") + val tokenResponse = OAuth2Service(context).refreshToken(session) + context.logger.info("Successfully refreshed access token") + session.tokenResponse = tokenResponse } private fun logAndReportProgress(msg: String) { @@ -155,22 +181,22 @@ 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() } } } - 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 b4a60668..4d29ed77 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,11 +1,18 @@ 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.OAuth2Service +import com.coder.toolbox.oauth.PKCEGenerator +import com.coder.toolbox.oauth.TokenEndpointAuthMethod +import com.coder.toolbox.oauth.getPreferredOrAvailable 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 @@ -16,12 +23,18 @@ import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrl 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_personal" /** * 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. */ @@ -51,7 +64,6 @@ class DeploymentUrlStep( RowGroup.RowField(signatureFallbackStrategyField), RowGroup.RowField(errorField) ) - } return RowGroup( RowGroup.RowField(urlField), @@ -73,27 +85,92 @@ 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 rawUrl = urlField.contentState.value + if (rawUrl.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } + try { - CoderCliSetupContext.url = validateRawUrl(url) + CoderSetupWizardContext.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { errorReporter.report("URL is invalid", e) return false } - if (context.settingsStore.requiresTokenAuth) { - CoderCliSetupWizardState.goToNextStep() - } else { - CoderCliSetupWizardState.goToLastStep() + + if (context.settingsStore.requiresMTlsAuth) { + CoderSetupWizardState.goToLastStep() + return true + } + if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable) { + try { + context.logger.info("Prefers OAuth2 authentication") + CoderSetupWizardContext.oauthSession = handleOAuth2(rawUrl) + return false + } catch (e: Exception) { + errorReporter.report("Failed to authenticate with OAuth2: ${e.message}", e) + return false + } } + // if all else fails try the good old API token auth + CoderSetupWizardState.goToNextStep() return true } + private suspend fun handleOAuth2(urlString: String): CoderOAuthSessionContext? { + val oauthService = OAuth2Service(context) + val authServer = oauthService.discoverMetadata(urlString) ?: return null + + context.logger.debug("registering coder-jetbrains-toolbox as client app") + val clientResponse = oauthService.registerClient( + authServer.registrationEndpoint, + ClientRegistrationRequest( + clientName = "coder-jetbrains-toolbox", + redirectUris = listOf(REDIRECT_URI), + grantTypes = listOf("authorization_code", "refresh_token"), + responseTypes = authServer.supportedResponseTypes, + 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_post" + } else { + "none" + } + ) + ) + + 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_method", "S256") + .addQueryParameter("code_challenge", codeChallenge) + .addQueryParameter("scope", OAUTH2_SCOPE) + .addQueryParameter("state", state) + .build() + .toString() + + context.logger.info("Launching browser for OAuth2 authentication") + context.desktop.browse(loginUrl) { + context.ui.showErrorInfoPopup(it) + } + + return CoderOAuthSessionContext( + clientId = clientResponse.clientId, + clientSecret = clientResponse.clientSecret, + tokenCodeVerifier = codeVerifier, + state = state, + tokenEndpoint = authServer.tokenEndpoint, + tokenAuthMethod = authServer.authMethodForTokenEndpoint.getPreferredOrAvailable() + ) + } + /** * Throws [MalformedURLException] if the given string violates RFC-2396 */ 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..54efb0a7 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/SuspendBiConsumer.kt @@ -0,0 +1,20 @@ +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/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index b449f40a..b50cdec8 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.apiTokenFor(CoderSetupWizardContext.url!!) ?: "" } } else { errorField.textState.update { @@ -46,23 +46,23 @@ class TokenStep( } } (linkField.urlState as MutableStateFlow).update { - CoderCliSetupContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + CoderSetupWizardContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } - 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") } 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/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 diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt deleted file mode 100644 index 8d503b91..00000000 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.coder.toolbox.views.state - -import java.net.URL - -/** - * Singleton that holds Coder CLI setup 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 { - /** - * The currently entered URL. - */ - var url: URL? = null - - /** - * The token associated with the URL. - */ - var token: String? = null - - /** - * Returns true if a URL is currently set. - */ - fun hasUrl(): Boolean = url != null - - /** - * Returns true if a token is currently set. - */ - fun hasToken(): Boolean = !token.isNullOrBlank() - - /** - * Returns true if URL or token is missing and auth is not yet possible. - */ - fun isNotReadyForAuth(): Boolean = !(hasUrl() && token != null) - - /** - * Resets both URL and token to null. - */ - fun reset() { - url = null - token = null - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt deleted file mode 100644 index 92a08451..00000000 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.coder.toolbox.views.state - - -/** - * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. - * - * This is used to persist the wizard's progress (i.e., current step) between visibility changes - * 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 { - private var currentStep = WizardStep.URL_REQUEST - - fun currentStep(): WizardStep = currentStep - - fun goToStep(step: WizardStep) { - currentStep = step - } - - fun goToNextStep() { - currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal + 1) % WizardStep.entries.size] - } - - fun goToPreviousStep() { - currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal - 1) % WizardStep.entries.size] - } - - fun goToLastStep() { - currentStep = WizardStep.CONNECT - } - - fun goToFirstStep() { - currentStep = WizardStep.URL_REQUEST - } -} - -enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, CONNECT; -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt new file mode 100644 index 00000000..b924250a --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt @@ -0,0 +1,78 @@ +package com.coder.toolbox.views.state + +import com.coder.toolbox.oauth.OAuthTokenResponse +import com.coder.toolbox.oauth.TokenEndpointAuthMethod +import java.net.URL + +/** + * 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 CoderSetupWizardContext { + /** + * The currently entered URL. + */ + var url: URL? = null + + /** + * The token associated with the URL. + */ + var token: String? = null + + /** + * The OAuth session context. + */ + var oauthSession: CoderOAuthSessionContext? = null + + /** + * Returns true if a URL is currently set. + */ + fun hasUrl(): Boolean = url != null + + /** + * Returns true if a token is currently set. + */ + fun hasToken(): Boolean = !token.isNullOrBlank() + + /** + * Returns true if an OAuth access token is currently set. + */ + 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() || hasOAuthSession())) + + /** + * Resets both URL and token to null. + */ + fun reset() { + url = null + token = null + oauthSession = null + } +} + +data class CoderOAuthSessionContext( + val clientId: String, + val clientSecret: String, + val tokenCodeVerifier: String, + val state: String, + val tokenEndpoint: String, + var tokenResponse: OAuthTokenResponse? = null, + 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 diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt new file mode 100644 index 00000000..81edd2aa --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt @@ -0,0 +1,47 @@ +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. + * + * This is used to persist the wizard's progress (i.e., current step) between visibility changes + * 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 CoderSetupWizardState { + private val currentStep = MutableStateFlow(WizardStep.URL_REQUEST) + val step: StateFlow = currentStep + + fun currentStep(): WizardStep = currentStep.value + + fun goToStep(step: WizardStep) { + currentStep.value = step + } + + fun goToNextStep() { + currentStep.value = WizardStep.entries.toTypedArray()[(currentStep.value.ordinal + 1) % WizardStep.entries.size] + } + + fun goToPreviousStep() { + val entries = WizardStep.entries.toTypedArray() + currentStep.value = entries[(currentStep.value.ordinal - 1 + entries.size) % entries.size] + } + + fun goToLastStep() { + currentStep.value = WizardStep.CONNECT + } + + fun goToFirstStep() { + currentStep.value = WizardStep.URL_REQUEST + } + + fun goToDone() { + currentStep.value = WizardStep.DONE + } +} + +enum class WizardStep { + URL_REQUEST, TOKEN_REQUEST, CONNECT, DONE; +} diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 6be1db2f..cfc3b0ae 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -194,4 +194,7 @@ msgid "Unstable connection detected" msgstr "" msgid "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" +msgstr "" + +msgid "Prefer OAuth2 if available over authentication via API Key" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index dc135230..a91e7baa 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -257,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") } } 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", ) } diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index eebd4247..7bad0dcd 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 @@ -52,6 +53,18 @@ internal class URLExtensionsTest { "?foo=bar&baz=" to mapOf("foo" to "bar", "baz" to ""), "?foo=bar&baz=qux" to mapOf("foo" to "bar", "baz" to "qux"), "?foo=bar=bar2&baz=qux" to mapOf("foo" to "bar=bar2", "baz" to "qux"), + // + decoded to space (application/x-www-form-urlencoded convention) + "?foo=hello+world" to mapOf("foo" to "hello world"), + // percent-encoded characters + "?foo=hello%20world" to mapOf("foo" to "hello world"), + "?foo=bar%3Dbaz" to mapOf("foo" to "bar=baz"), + "?foo=caf%C3%A9" to mapOf("foo" to "café"), + // OAuth cancel callback: error_description uses + for spaces + "?error=access_denied&error_description=The+resource+owner+or+authorization+server+denied+the+request.&state=abc" to mapOf( + "error" to "access_denied", + "error_description" to "The resource owner or authorization server denied the request.", + "state" to "abc", + ), ) tests.forEach { assertEquals( @@ -152,4 +165,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() } + } }