diff --git a/build.gradle.kts b/build.gradle.kts index 5480e6f2..ce286de2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,11 +64,11 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.moshi) implementation(libs.bundles.bouncycastle) + testImplementation(kotlin("test")) testImplementation(libs.coroutines.test) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) - testImplementation(libs.coroutines.test) } val extension = ExtensionJson( @@ -204,21 +204,13 @@ tasks.register("cleanAll", Delete::class.java) { private fun getPluginInstallDir(): Path { val userHome = System.getProperty("user.home").let { Path.of(it) } - val toolboxCachesDir = when { + val pluginsDir = when { SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") // currently this is the location that TBA uses on Linux SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") SystemInfoRt.isMac -> userHome / "Library" / "Caches" else -> error("Unknown os") - } / "JetBrains" / "Toolbox" - - val pluginsDir = when { - SystemInfoRt.isWindows || - SystemInfoRt.isLinux || - SystemInfoRt.isMac -> toolboxCachesDir - - else -> error("Unknown os") - } / "plugins" + } / "JetBrains" / "Toolbox" / "plugins" return pluginsDir / extension.id } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 2eb5867e..50903001 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -3,7 +3,10 @@ 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.OAuthTokenResponse +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus @@ -17,15 +20,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 @@ -35,6 +39,7 @@ import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -46,9 +51,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import okhttp3.Credentials 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 @@ -79,7 +84,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 +270,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 +278,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 +346,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 +403,89 @@ 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() + val code = params["code"] + val state = params["state"] + + if (code != null && state != null && state == CoderSetupWizardContext.oauthSession?.state) { + if (CoderSetupWizardContext.oauthSession == null) { + context.logAndShowError( + "Failed to handle OAuth code", + "We received an OAuth code but our OAuth session is null" + ) + return + } + exchangeOAuthCodeForToken(code) + } + } + + private suspend fun exchangeOAuthCodeForToken(code: String) { + try { + context.logger.info("Handling OAuth callback...") + val session = CoderSetupWizardContext.oauthSession ?: return + + // we need to make a POST request to the token endpoint + val formBodyBuilder = okhttp3.FormBody.Builder() + .add("code", code) + .add("grant_type", "authorization_code") + .add("code_verifier", session.tokenCodeVerifier) + .add("redirect_uri", "jetbrains://gateway/com.coder.toolbox/auth") + + val requestBuilder = okhttp3.Request.Builder() + .url(session.tokenEndpoint) + + when (session.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> { + requestBuilder.header("Authorization", Credentials.basic(session.clientId, session.clientSecret)) + } + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> { + formBodyBuilder.add("client_id", session.clientId) + formBodyBuilder.add("client_secret", session.clientSecret) + } + + else -> { + formBodyBuilder.add("client_id", session.clientId) + } + } + + val request = requestBuilder + .post(formBodyBuilder.build()) + .build() + + val response = CoderHttpClientBuilder.default(context) + .newCall(request) + .execute() + + if (!response.isSuccessful) { + context.logAndShowError("OAuth Error", "Failed to exchange code for token") + return + } + + val responseBody = response.body?.string() ?: return + val adapter = Moshi + .Builder() + .build() + .adapter(OAuthTokenResponse::class.java) + val tokenResponse = adapter.fromJson(responseBody) ?: return + + session.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 +518,7 @@ class CoderRemoteProvider( context, url, token, + null, PluginManager.pluginInfo.version, ).apply { initializeSession() } val newCli = CoderCLIManager(context, url).apply { @@ -462,24 +547,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 +590,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 +616,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 suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) { + oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) } + context.secrets.storeOAuthFor(url.toString(), oauthSessionCtx) + } - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + 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 +654,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 9963e753..4b81c3d1 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 @@ -148,15 +145,8 @@ class CoderCLIManager( val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") private fun createDownloadService(): CoderDownloadService { - val interceptors = buildList { - add((Interceptors.userAgent(PluginManager.pluginInfo.version))) - add(Interceptors.logging(context)) - } - val okHttpClient = CoderHttpClientBuilder.build( - context, - interceptors, - ReloadableTlsContext(context.settingsStore.readOnly().tls) - ) + + val okHttpClient = CoderHttpClientBuilder.default(context) val retrofit = Retrofit.Builder() .baseUrl(deploymentURL.toString()) 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..2bcbe1e2 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * DCR response + */ +@JsonClass(generateAdapter = true) +data class ClientRegistrationResponse( + @field:Json(name = "client_id") val clientId: String, + @field:Json(name = "client_secret") val clientSecret: String, + @field:Json(name = "client_name") val clientName: String, + @field:Json(name = "redirect_uris") val redirectUris: List, + @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 +) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt new file mode 100644 index 00000000..b664f863 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.oauth + +import com.jetbrains.toolbox.api.core.auth.Account +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CoderAccount( + override val id: String, + override val fullName: String, + val baseUrl: String, + val refreshUrl: String, + val clientId: String, + val clientSecret: String? = null +) : Account \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt new file mode 100644 index 00000000..f5d40929 --- /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.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Url + +interface CoderAuthorizationApi { + @GET(".well-known/oauth-authorization-server") + suspend fun discoveryMetadata(): Response + + @POST("oauth2/register") + suspend fun registerClient( + @Body request: ClientRegistrationRequest + ): Response + + @FormUrlEncoded + @POST + suspend fun refreshToken( + @Url url: String, + @Field("grant_type") grantType: String = "refresh_token", + @Field("client_id") clientId: String, + @Field("client_secret") clientSecret: String, + @Field("refresh_token") refreshToken: String + ): Response + + @FormUrlEncoded + @POST + suspend fun refreshToken( + @Url url: String, + @Header("Authorization") authorization: String, + @Field("grant_type") grantType: String = "refresh_token", + @Field("refresh_token") refreshToken: String + ): Response +} diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt new file mode 100644 index 00000000..3c43ede5 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -0,0 +1,139 @@ +package com.coder.toolbox.oauth + +import com.coder.toolbox.sdk.convertors.UUIDConverter +import com.coder.toolbox.sdk.v2.CoderV2RestFacade +import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.auth.AuthConfiguration +import com.jetbrains.toolbox.api.core.auth.ContentType +import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED +import com.jetbrains.toolbox.api.core.auth.OAuthToken +import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface +import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration +import com.squareup.moshi.Moshi +import okhttp3.Credentials +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.URL + +class CoderOAuthManager : PluginAuthInterface { + private val moshi = Moshi.Builder().add(UUIDConverter()).build() + + override fun serialize(account: CoderAccount): String { + val adapter = moshi.adapter(CoderAccount::class.java) + return adapter.toJson(account) + } + + override fun deserialize(string: String): CoderAccount { + val adapter = moshi.adapter(CoderAccount::class.java) + return adapter.fromJson(string) ?: throw IllegalArgumentException("Invalid CoderAccount JSON") + } + + override suspend fun createAccount( + token: OAuthToken, + config: AuthConfiguration + ): CoderAccount { + val user = fetchUser(token, config.baseUrl.toURL()) + return CoderAccount( + user.id.toString(), + user.username, + config.baseUrl, + config.tokenUrl, + config.tokenParams["client_id"]!!, + config.tokenParams["client_secret"] + ) + } + + override suspend fun updateAccount( + token: OAuthToken, + account: CoderAccount + ): CoderAccount { + val user = fetchUser(token, account.baseUrl.toURL()) + return CoderAccount( + user.id.toString(), + user.username, + account.baseUrl, + account.refreshUrl, + account.clientId, + account.clientSecret + ) + } + + private suspend fun fetchUser(token: OAuthToken, baseUrl: URL): com.coder.toolbox.sdk.v2.models.User { + val httpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Authorization", token.authorizationHeader) + .build() + chain.proceed(request) + } + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(httpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + val service = retrofit.create(CoderV2RestFacade::class.java) + return service.me().body() ?: throw IllegalStateException("Could not fetch user info") + } + + override fun createAuthConfig(loginConfiguration: CoderOAuthCfg): AuthConfiguration { + val codeVerifier = PKCEGenerator.generateCodeVerifier() + val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) + + return AuthConfiguration( + authParams = mapOf( + "client_id" to loginConfiguration.clientId, + "response_type" to "code", + "code_challenge" to codeChallenge + ), + tokenParams = buildMap { + put("grant_type", "authorization_code") + put("code", loginConfiguration.clientSecret) + if (loginConfiguration.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_POST) { + put("client_id", loginConfiguration.clientId) + put("client_secret", loginConfiguration.clientSecret) + } + put("redirect_uri", loginConfiguration.redirectUri) + }, + baseUrl = loginConfiguration.baseUrl, + authUrl = loginConfiguration.authUrl, + tokenUrl = loginConfiguration.tokenUrl, + codeChallengeParamName = "code_challenge", + codeChallengeMethod = "S256", + verifierParamName = "code_verifier", + authorization = if (loginConfiguration.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_BASIC) { + Credentials.basic(loginConfiguration.clientId, loginConfiguration.clientSecret) + } else { + null + } + ) + } + + override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { + return object : RefreshConfiguration { + override val refreshUrl: String = account.baseUrl + override val parameters: Map = buildMap { + put("grant_type", "refresh_token") + put("client_id", account.clientId) + if (account.clientSecret != null) { + put("client_secret", account.clientSecret) + } + } + override val authorization: String? = null + override val contentType: ContentType = FORM_URL_ENCODED + } + } +} + +data class CoderOAuthCfg( + val baseUrl: String, + val authUrl: String, + val tokenUrl: String, + val clientId: String, + val clientSecret: String, + val tokenAuthMethod: TokenEndpointAuthMethod, + val redirectUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt new file mode 100644 index 00000000..5eb877e7 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthSession.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CoderOAuthSession( + val clientId: String, + val clientSecret: String, + val tokenCodeVerifier: String, + val state: String, + val tokenEndpoint: String, + var accessToken: String? = null, + var refreshToken: String? = null, + val tokenAuthMethod: TokenEndpointAuthMethod +) 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..7c58eddf --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/OAuthTokenResponse.kt @@ -0,0 +1,16 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * 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? +) 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 d96e82ad..7a07b3b2 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.OAuthTokenResponse +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.LoggingConverterFactory @@ -21,10 +23,17 @@ 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.Credentials +import okhttp3.FormBody import okhttp3.OkHttpClient +import okhttp3.Request import org.zeroturnaround.exec.ProcessExecutor import retrofit2.Response import retrofit2.Retrofit @@ -42,13 +51,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 +83,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 +356,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 +391,44 @@ open class CoderRestClient( } } + private suspend fun refreshToken() { + val requestBuilder = Request.Builder().url(oauthContext!!.tokenEndpoint) + val formBodyBuilder = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", oauthContext.tokenResponse?.refreshToken!!) + + when (oauthContext.tokenAuthMethod) { + TokenEndpointAuthMethod.CLIENT_SECRET_BASIC -> { + requestBuilder.header( + "Authorization", + Credentials.basic(oauthContext.clientId, oauthContext.clientSecret ?: "") + ) + } + + TokenEndpointAuthMethod.CLIENT_SECRET_POST -> { + formBodyBuilder.add("client_id", oauthContext.clientId) + formBodyBuilder.add("client_secret", oauthContext.clientSecret ?: "") + } + + else -> { + formBodyBuilder.add("client_id", oauthContext.clientId) + } + } + + val request = requestBuilder.post(formBodyBuilder.build()).build() + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + throw APIResponseException("refresh token", url, response.code, null) + } + + val responseBody = response.body?.string() + val newAuthResponse = moshi.adapter(OAuthTokenResponse::class.java).fromJson(responseBody!!) + 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 2c19e93b..99577e3c 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -170,6 +170,12 @@ interface ReadOnlyCoderSettings { */ val networkInfoDir: String + /** + * Indicates whether API tokens should be used for authentication instead + * of OAuth2 when OAuth2 is available. Defaults to false. + */ + val preferAuthViaApiToken: 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 b365d680..b531abe7 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -85,6 +85,8 @@ class CoderSettingsStore( .resolve("ssh-network-metrics") .normalize() .toString() + override val preferAuthViaApiToken: Boolean + get() = store[PREFER_AUTH_VIA_API_TOKEN]?.toBooleanStrictOrNull() ?: false override val workspaceViewUrl: String? get() = store[WORKSPACE_VIEW_URL] diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 7cd9ec5c..5aeae52a 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -57,3 +57,4 @@ internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" +internal const val PREFER_AUTH_VIA_API_TOKEN = "preferAuthViaApiToken" diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index 7e2a8e35..2f50ab5a 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -8,6 +8,12 @@ import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.toBaseURL(): URL { + val url = this.toURL() + val port = if (url.port != -1) ":${url.port}" else "" + return URI.create("${url.protocol}://${url.host}$port").toURL() +} + fun String.validateStrictWebUrl(): WebUrlValidationResult = try { val uri = URI(this) @@ -21,15 +27,18 @@ fun String.validateStrictWebUrl(): WebUrlValidationResult = try { "The URL \"$this\" is missing a scheme (like https://). " + "Please enter a full web address like \"https://example.com\"" ) + uri.scheme?.lowercase() !in setOf("http", "https") -> Invalid( "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" ) + uri.authority.isNullOrBlank() -> Invalid( "The URL \"$this\" does not include a valid website name. " + "Please enter a full web address like \"https://example.com\"" ) + else -> Valid } } catch (_: Exception) { diff --git a/src/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/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 3c1c8ef9..af4b6dd9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -3,14 +3,20 @@ 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.CoderAuthorizationApi +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory +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 import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import com.squareup.moshi.Moshi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -18,8 +24,11 @@ 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 okhttp3.Credentials +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.URL private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -30,10 +39,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 @@ -47,19 +56,26 @@ class ConnectStep( ) override fun onVisible() { + context.logger.info(">> ConnectStep visible") errorReporter.flush() errorField.textState.update { 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 +83,14 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = CoderCliSetupContext.url + context.logger.info(">> ConnectStep#onConnect called") + 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 +104,22 @@ class ConnectStep( // 1. Extract the logic into a reusable suspend lambda val connectionLogic: suspend CoroutineScope.() -> Unit = { try { + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) { + refreshOAuthToken(url) + } + + val oauthSession = + if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.hasOAuthSession()) CoderSetupWizardContext.oauthSession!!.copy() else null + 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 +136,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,18 +167,54 @@ 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() - } + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } + + private suspend fun refreshOAuthToken(url: URL) { + val session = CoderSetupWizardContext.oauthSession ?: return + if (!session.tokenResponse?.accessToken.isNullOrBlank()) return + val refreshToken = session.tokenResponse?.refreshToken ?: return + + logAndReportProgress("Refreshing OAuth token...") + val service = createAuthorizationService(url.toString()) + + val tokenResponse = if (session.tokenAuthMethod == TokenEndpointAuthMethod.CLIENT_SECRET_BASIC) { + service.refreshToken( + url = session.tokenEndpoint, + authorization = Credentials.basic(session.clientId, session.clientSecret), + refreshToken = refreshToken + ) + } else { + service.refreshToken( + url = session.tokenEndpoint, + clientId = session.clientId, + clientSecret = session.clientSecret, + refreshToken = refreshToken + ) + } + + if (tokenResponse.isSuccessful && tokenResponse.body() != null) { + context.logger.info("Successfully refreshed access token") + session.tokenResponse = tokenResponse.body() } else { - // Runs asynchronously using the context's scope - signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + throw Exception("Failed to refresh OAuth token: ${tokenResponse.code()} ${tokenResponse.message()}") } } + private fun createAuthorizationService(urlString: String): CoderAuthorizationApi { + return Retrofit.Builder() + .baseUrl(urlString) + .client(CoderHttpClientBuilder.default(context)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + } + private fun logAndReportProgress(msg: String) { context.logger.info(msg) statusField.textState.update { context.i18n.pnotr(msg) } @@ -155,22 +225,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..993cf67e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,11 +1,21 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.browser.browse +import com.coder.toolbox.oauth.AuthorizationServer +import com.coder.toolbox.oauth.ClientRegistrationRequest +import com.coder.toolbox.oauth.CoderAuthorizationApi +import com.coder.toolbox.oauth.PKCEGenerator +import com.coder.toolbox.oauth.TokenEndpointAuthMethod +import com.coder.toolbox.oauth.getPreferredOrAvailable +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory 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 @@ -14,14 +24,23 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrl +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory 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" /** * 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. */ @@ -40,8 +59,15 @@ class DeploymentUrlStep( context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + private val preferOAuth2IfAvailable = CheckboxField( + true, + context.i18n.ptrl("Prefer OAuth2 if available over authentication via API Key") + ) + private val errorField = ValidationErrorField(context.i18n.pnotr("")) + val okHttpClient = CoderHttpClientBuilder.default(context) + override val panel: RowGroup get() { if (!context.settingsStore.disableSignatureVerification) { @@ -49,6 +75,7 @@ class DeploymentUrlStep( RowGroup.RowField(urlField), RowGroup.RowField(emptyLine), RowGroup.RowField(signatureFallbackStrategyField), + RowGroup.RowField(preferOAuth2IfAvailable), RowGroup.RowField(errorField) ) @@ -73,27 +100,127 @@ 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 && preferOAuth2IfAvailable.checkedState.value) { + try { + CoderSetupWizardContext.oauthSession = handleOAuth2(rawUrl) + return false + } catch (e: Exception) { + errorReporter.report("Failed to check OAuth support: ${e.message}", e) + } + } + // if all else fails try the good old API token auth + CoderSetupWizardState.goToNextStep() return true } + private suspend fun handleOAuth2(urlString: String): CoderOAuthSessionContext? { + val service = createAuthorizationService(urlString) + val authServer = fetchDiscoveryMetadata(service) ?: return null + + context.logger.debug("registering coder-jetbrains-toolbox as client app") + val clientResponse = registerClient(service, authServer) ?: return null + + 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() + ) + } + + private fun createAuthorizationService(urlString: String): CoderAuthorizationApi { + return Retrofit.Builder() + .baseUrl(urlString) + .client(okHttpClient) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + } + + private suspend fun fetchDiscoveryMetadata(service: CoderAuthorizationApi): AuthorizationServer? { + val response = service.discoveryMetadata() + if (response.isSuccessful) { + return response.body() + } + context.logger.info("OAuth discovery failed: ${response.code()} ${response.message()} || ${response.errorBody()}") + return null + } + + private suspend fun registerClient( + service: CoderAuthorizationApi, + authServer: AuthorizationServer + ): com.coder.toolbox.oauth.ClientRegistrationResponse? { + // TODO - until https://github.com/coder/coder/issues/20370 is delivered + val clientResponse = service.registerClient( + 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" + } + ) + ) + + if (clientResponse.isSuccessful) { + return requireNotNull(clientResponse.body()) { "Successful response returned null body or client registration metadata" } + } else { + context.logger.error(">> ${clientResponse.code()} ${clientResponse.message()} || ${clientResponse.errorBody()}") + return null + } + } + /** * 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/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 15ebfcdd..342e1ca1 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -51,7 +51,7 @@ import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException -import org.zeroturnaround.exec.ProcessInitException +import java.io.IOException import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy @@ -333,10 +333,10 @@ internal class CoderCLIManagerTest { URL("https://foo") ) - assertFailsWith( - exceptionClass = ProcessInitException::class, - block = { ccm.login("fake-token") }, - ) + val exception = assertFailsWith { + runBlocking { ccm.login("fake-token") } + } + assertContains(exception.message!!, Regex("Could not execute .*")) } @Test @@ -722,7 +722,7 @@ internal class CoderCLIManagerTest { fun testFailVersionParse() { val tests = mapOf( - null to ProcessInitException::class, + null to IOException::class, echo("""{"foo": true, "baz": 1}""") to MissingVersionException::class, echo("""{"version": ""}""") to MissingVersionException::class, echo("""v0.0.1""") to JsonEncodingException::class, @@ -935,7 +935,7 @@ internal class CoderCLIManagerTest { val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( - exceptionClass = ProcessInitException::class, + exceptionClass = IOException::class, block = { ccm.version() }, ) } 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..8b3be17b 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -4,6 +4,7 @@ import java.net.URI import java.net.URL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith internal class URLExtensionsTest { @Test @@ -152,4 +153,37 @@ internal class URLExtensionsTest { result ) } + + @Test + fun `returns base URL without path or query`() { + val fullUrl = "https://example.com/path/to/page?param=1" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com"), result) + } + + @Test + fun `includes port if specified`() { + val fullUrl = "https://example.com:8080/api/v1/resource" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com:8080"), result) + } + + @Test + fun `handles subdomains correctly`() { + val fullUrl = "http://api.subdomain.example.org/v2/users" + val result = fullUrl.toBaseURL() + assertEquals(URL("http://api.subdomain.example.org"), result) + } + + @Test + fun `handles simple domain without path`() { + val fullUrl = "https://test.com" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://test.com"), result) + } + + @Test + fun `throws exception for invalid URL`() { + assertFailsWith { "ht!tp://bad_url".toBaseURL() } + } }