Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
- SSH connections being blocked while non-blocking startup scripts run or after startup scripts fail or time out.
- Automatic connection not starting when a workspace agent becomes connectable after the initial workspace load.

### Security

- Ask for confirmation before a workspace link opens an unknown Coder deployment. Links select the deployment by
scheme, host, and port. For a known deployment, Toolbox asks before using a link token if the accounts differ or
it cannot verify the saved account.
- Store Toolbox API tokens separately for each deployment scheme, host, and port to prevent cross-deployment reuse.
Tokens saved by older plugin versions require one new sign-in because they were stored without an origin.
- Prevent delayed OAuth refreshes from updating CLI credentials for a different deployment.

## 0.9.5 - 2026-10-30

### Added
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dependencies {
implementation(libs.bundles.bouncycastle)

testImplementation(kotlin("test"))
testImplementation(libs.junit.jupiter.params)
testImplementation(libs.coroutines.test)
testImplementation(libs.mokk)
testImplementation(libs.bundles.toolbox.plugin.api)
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" }
mokk = { module = "io.mockk:mockk", version.ref = "mockk" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" }
marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" }
bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" }
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
Expand Down
370 changes: 234 additions & 136 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ suspend fun ensureCLI(
class CoderCLIManager(
private val context: CoderToolboxContext,
// The URL of the deployment this CLI is for.
private val deploymentURL: URL,
internal val deploymentURL: URL,
private val currentOs: OS? = getOS(),
) {
internal val usesTokenAuth = context.settingsStore.requiresTokenAuth
Expand Down
113 changes: 100 additions & 13 deletions src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package com.coder.toolbox.store

import com.coder.toolbox.oauth.TokenEndpointAuthMethod
import com.coder.toolbox.util.origin
import com.coder.toolbox.views.state.CoderOAuthSessionContext
import com.coder.toolbox.views.state.StoredOAuthSession
import com.jetbrains.toolbox.api.core.PluginSecretStore
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
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"
private const val LAST_DEPLOYMENT_URL_KEY = "last-deployment-url"

/**
* Provides Coder secrets backed by the secrets store service.
Expand All @@ -20,16 +23,61 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
message = "The URL is now stored the JSON backed settings store. Use CoderSettingsStore#lastDeploymentURL",
replaceWith = ReplaceWith("context.settingsStore.lastDeploymentURL")
)
val lastDeploymentURL: String = store["last-deployment-url"] ?: ""
val lastDeploymentURL: String = store[LAST_DEPLOYMENT_URL_KEY] ?: ""

fun apiTokenFor(url: URL): String? = store[url.host]
fun apiTokenFor(url: URL): String? = apiSessionKey(url)?.let { store[it] }

fun storeApiTokenFor(url: URL, apiToken: String) {
store[url.host] = apiToken
val key = requireNotNull(apiSessionKey(url)) { "Session credentials require an HTTP(S) deployment URL" }
store[key] = apiToken
clearLegacyApiToken(url)
}

private fun apiSessionKey(url: URL): String? =
url.origin()?.let { "api-session:$it" }
Comment thread
fioan89 marked this conversation as resolved.
Resolved

private fun clearLegacyApiToken(url: URL) {
if (url.host.isEmpty()) return
// Older versions used URL.host verbatim. A link can also supply the host in lowercase.
for (host in setOf(url.host, url.host.lowercase())) {
// A single-label hostname can share the key used for the last deployment URL.
if (host == LAST_DEPLOYMENT_URL_KEY && store[host]?.toHttpUrlOrNull() != null) continue
store.clear(host)
}
}

fun isUrlTrusted(url: URL): Boolean {
val requested = url.toString().toHttpUrlOrNull() ?: return false
val origin = requested.origin()
val saved = store["deployment-url:$origin"]
return if (saved != null) {
saved.toHttpUrlOrNull() == requested
} else {
// Older origin-bound tokens have no record of a deployment path.
requested.toString() == "$origin/" && !apiTokenFor(url).isNullOrBlank()
}
}

fun trustDeployment(url: URL) {
val address = requireNotNull(url.toString().toHttpUrlOrNull()) { "Deployment trust requires an HTTP(S) URL" }
val key = "deployment-url:${address.origin()}"
if (store[key]?.toHttpUrlOrNull() != address) store[key] = url.toString()
}

@Synchronized
fun clearSessionFor(url: URL) {
store.clear(url.host)
apiSessionKey(url)?.let {
store.clear(it)
clearLegacyApiToken(url)
}
url.origin()?.let { origin ->
clearOAuthSession(origin)
legacyOAuthAddresses(url.toString(), origin).forEach(::clearOAuthSession)
store.clear("deployment-url:$origin")
}
}

private fun clearOAuthSession(url: String) {
listOf(
OAUTH_CLIENT_ID_PREFIX, OAUTH_CLIENT_SECRET_PREFIX, OAUTH_REFRESH_TOKEN,
OAUTH_TOKEN_AUTH_METHOD, OAUTH_TOKEN_ENDPOINT
Expand All @@ -38,13 +86,31 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
}
}

// Older versions used the URL exactly as entered, including its path or trailing slash.
private fun legacyOAuthAddresses(url: String, origin: String): Set<String> =
setOf(url, "$origin/") - origin

@Synchronized
fun oauthSessionFor(url: String): StoredOAuthSession? {
val clientId = store["$OAUTH_CLIENT_ID_PREFIX-$url"]
val origin = url.toHttpUrlOrNull()?.origin() ?: return null
readOAuthSession(origin)?.let { return it }
val (legacyUrl, legacy) = legacyOAuthAddresses(url, origin)
.firstNotNullOfOrNull { address -> readOAuthSession(address)?.let { address to it } } ?: return null
// Move the session on first use so later lookups and logout need only the origin.
runCatching {
writeOAuthSession(origin, legacy)
clearOAuthSession(legacyUrl)
}
return legacy
}

private fun readOAuthSession(url: String): StoredOAuthSession? {
val clientId = store["$OAUTH_CLIENT_ID_PREFIX-$url"] ?: return null
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) {
if (clientSecret == null || refreshToken == null || tokenAuthMethod == null || tokenEndpoint == null) {
return null
}

Expand All @@ -57,13 +123,34 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
)
}

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
private fun writeOAuthSession(origin: String, session: StoredOAuthSession) {
if (readOAuthSession(origin)?.copy(refreshToken = session.refreshToken) == session) {
store["$OAUTH_REFRESH_TOKEN-$origin"] = session.refreshToken
return
}
// Publish a new registration's client ID last so partial writes cannot be read as a session.
store.clear("$OAUTH_CLIENT_ID_PREFIX-$origin")
store["$OAUTH_CLIENT_SECRET_PREFIX-$origin"] = session.clientSecret
store["$OAUTH_REFRESH_TOKEN-$origin"] = session.refreshToken
store["$OAUTH_TOKEN_AUTH_METHOD-$origin"] = session.tokenAuthMethod.name
store["$OAUTH_TOKEN_ENDPOINT-$origin"] = session.tokenEndpoint
store["$OAUTH_CLIENT_ID_PREFIX-$origin"] = session.clientId
}

@Synchronized
fun storeOAuthFor(url: String, oAuthSessionCtx: CoderOAuthSessionContext) {
val refreshToken = oAuthSessionCtx.tokenResponse?.refreshToken ?: return
val origin =
requireNotNull(url.toHttpUrlOrNull()?.origin()) { "OAuth credentials require an HTTP(S) deployment URL" }
writeOAuthSession(
origin, StoredOAuthSession(
clientId = oAuthSessionCtx.clientId,
clientSecret = oAuthSessionCtx.clientSecret,
refreshToken = refreshToken,
tokenAuthMethod = oAuthSessionCtx.tokenAuthMethod,
tokenEndpoint = oAuthSessionCtx.tokenEndpoint,
)
)
legacyOAuthAddresses(url, origin).forEach(::clearOAuthSession)
}
}
17 changes: 17 additions & 0 deletions src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,30 @@ package com.coder.toolbox.util

import com.coder.toolbox.util.WebUrlValidationResult.Invalid
import com.coder.toolbox.util.WebUrlValidationResult.Valid
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.IDN
import java.net.URI
import java.net.URL
import java.net.URLDecoder

fun String.toURL(): URL = URI.create(this).toURL()

/** Canonical HTTP(S) origin, without credentials or a path; default ports are omitted. */
fun URL.origin(): String? = toString().toHttpUrlOrNull()?.origin()

fun HttpUrl.origin(): String = newBuilder()
.username("")
.password("")
.encodedPath("/")
.query(null)
.fragment(null)
.build()
.toString()
.removeSuffix("/")

fun URL.hasSameOrigin(other: URL?): Boolean = origin()?.let { it == other?.origin() } ?: false

fun String.toBaseURL(): URL {
val url = this.toURL()
val port = if (url.port != -1) ":${url.port}" else ""
Expand Down
Loading
Loading