Skip to content

Commit 1081e74

Browse files
authored
fix: OAuth2 authorization token revocation flow (#298)
No error was surfaced to the user for the case where the authorization for the plugin was revoked from the dashboard by the user. In fact the plugin continued to list the workspaces giving the false impression that everything is fine and dandy. Hoewever, if there was any action like starting or stopping a workspace then an API response error was listed. The issue was mainly caused by the retry logic from the htttp client. There is a sequence that was trying to detect 401 - unauthorized responses from the server when workspaces were listed. Before proceeding with the next step of trying to refresh the authorziation token - we made a quick test to check if the token was not refreshed in the meantime by another coroutine. But the logic was looking at the wrong header for comparing the access token ("Bearer" instead of "Coder-Session-Token"). But that was only a part of the problem. The second issue was that errors related to authorization token refresh were simply logged without being reported back to the UI or to the main polling loop. And in fact the polling loop continued to run without any success and without any error reported. The fix involved a bit of code refactoring but the gist is that access token refresh errors are now reported to main polling loop, which: - stops polling the workspaces - resets the screen to the main login wizard - displays a nice error regarding token refresh failure. - resolves https://linear.app/codercom/issue/DEVEX-221
1 parent 784ce9a commit 1081e74

9 files changed

Lines changed: 23 additions & 77 deletions

File tree

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.coder.toolbox.oauth.OAuthTokenResponse
88
import com.coder.toolbox.plugin.PluginManager
99
import com.coder.toolbox.sdk.CoderRestClient
1010
import com.coder.toolbox.sdk.ex.APIResponseException
11+
import com.coder.toolbox.sdk.ex.OAuthTokenResponseException
1112
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
1213
import com.coder.toolbox.util.CoderProtocolHandler
1314
import com.coder.toolbox.util.DialogUi
@@ -158,7 +159,7 @@ class CoderRemoteProvider(
158159
context.logger.info("wake-up from an OS sleep was detected")
159160
} else {
160161
context.logger.error(ex, "workspace polling error encountered")
161-
if (ex is APIResponseException && ex.isTokenExpired) {
162+
if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) {
162163
close()
163164
context.envPageManager.showPluginEnvironmentsPage()
164165
errorBuffer.add(ex)

src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,3 @@ data class ClientRegistrationErrorResponse(
4343
}
4444
}
4545
}
46-
47-
class ClientRegistrationException(message: String) : Exception(message)

src/main/kotlin/com/coder/toolbox/oauth/OAuth2Client.kt

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.coder.toolbox.oauth
33
import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.sdk.CoderHttpClientBuilder
55
import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
6+
import com.coder.toolbox.sdk.ex.ClientRegistrationException
7+
import com.coder.toolbox.sdk.ex.OAuthTokenResponseException
68
import com.coder.toolbox.views.state.CoderOAuthSessionContext
79
import com.squareup.moshi.Moshi
810
import okhttp3.Credentials
@@ -33,13 +35,9 @@ class OAuth2Client(private val context: CoderToolboxContext) {
3335
}
3436

3537
val errorBody = response.errorBody()?.string()
36-
val registrationError = errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) }
37-
val errorMessage = if (registrationError != null) {
38-
"OAuth2 client registration failed: ${registrationError.toMessage()}"
39-
} else {
40-
"OAuth2 client registration failed with status ${response.code()}: ${response.message()}"
41-
}
42-
context.logger.error(errorMessage)
38+
val registrationError =
39+
errorBody?.let { ClientRegistrationErrorResponse.fromJson(it) }?.toMessage() ?: response.message()
40+
val errorMessage = "OAuth2 client registration failed with status ${response.code()}: $registrationError"
4341
throw ClientRegistrationException(errorMessage)
4442
}
4543

@@ -114,14 +112,9 @@ class OAuth2Client(private val context: CoderToolboxContext) {
114112
}
115113

116114
val errorBody = response.errorBody()?.string()
117-
val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) }
118-
val errorMessage = if (tokenError != null) {
119-
"Failed to $action: ${tokenError.toMessage()}"
120-
} else {
121-
"Failed to $action. Response code: ${response.code()} ${response.message()}"
122-
}
123-
context.logger.error(errorMessage)
124-
throw Exception(errorMessage)
115+
val tokenError = errorBody?.let { OAuthTokenErrorResponse.fromJson(it) }?.toMessage() ?: response.message()
116+
val errorMessage = "Failed to $action. Response code: ${response.code()} $tokenError"
117+
throw OAuthTokenResponseException(errorMessage)
125118
}
126119

127120
private fun createAuthorizationService(): CoderAuthorizationApi {

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
99
import com.coder.toolbox.sdk.convertors.OSConverter
1010
import com.coder.toolbox.sdk.convertors.UUIDConverter
1111
import com.coder.toolbox.sdk.ex.APIResponseException
12+
import com.coder.toolbox.sdk.interceptors.CODER_SESSION_TOKEN_HEADER_NAME
1213
import com.coder.toolbox.sdk.interceptors.Interceptors
1314
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
1415
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
@@ -358,7 +359,7 @@ open class CoderRestClient(
358359
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED && oauthContext.hasRefreshToken()) {
359360
val tokenRefreshed = refreshMutex.withLock {
360361
// Check if the token was already refreshed while we were waiting for the lock.
361-
if (response.raw().request.header("Authorization") != "Bearer ${oauthContext?.tokenResponse?.accessToken}") {
362+
if (response.raw().request.header(CODER_SESSION_TOKEN_HEADER_NAME) != oauthContext?.tokenResponse?.accessToken) {
362363
return@withLock true
363364
}
364365
return@withLock try {
@@ -372,7 +373,8 @@ open class CoderRestClient(
372373
true
373374
} catch (e: Exception) {
374375
context.logger.error(e, "Failed to refresh access token")
375-
false
376+
// propagate the exception to the main workspace polling loop
377+
throw e
376378
}
377379
}
378380
if (tokenRefreshed) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.coder.toolbox.sdk.ex
2+
3+
sealed class OAuth2ErrorException(message: String?) : Exception(message)
4+
5+
class ClientRegistrationException(message: String) : OAuth2ErrorException(message)
6+
class OAuthTokenResponseException(message: String?) : OAuth2ErrorException(message)

src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.coder.toolbox.util.getOS
77
import okhttp3.Interceptor
88
import java.net.URL
99

10+
const val CODER_SESSION_TOKEN_HEADER_NAME = "Coder-Session-Token"
11+
1012
/**
1113
* Factory of okhttp interceptors
1214
*/
@@ -19,7 +21,7 @@ object Interceptors {
1921
return Interceptor { chain ->
2022
chain.proceed(
2123
chain.request().newBuilder()
22-
.addHeader("Coder-Session-Token", token)
24+
.addHeader(CODER_SESSION_TOKEN_HEADER_NAME, token)
2325
.build()
2426
)
2527
}

src/main/kotlin/com/coder/toolbox/util/Dialogs.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ import com.jetbrains.toolbox.api.ui.components.TextType
1111
*/
1212
class DialogUi(private val context: CoderToolboxContext) {
1313

14-
suspend fun confirm(title: LocalizableString, description: LocalizableString): Boolean {
15-
return context.ui.showOkCancelPopup(title, description, context.i18n.ptrl("Yes"), context.i18n.ptrl("No"))
16-
}
17-
1814
suspend fun ask(
1915
title: LocalizableString,
2016
description: LocalizableString,

src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@ import kotlinx.coroutines.flow.first
55
import kotlinx.coroutines.withTimeoutOrNull
66
import kotlin.time.Duration
77

8-
/**
9-
* Suspends the coroutine until first true value is received.
10-
*/
11-
suspend fun StateFlow<Boolean>.waitForTrue() = this.first { it }
12-
138
/**
149
* Suspends the coroutine until first false value is received.
1510
*/

src/main/kotlin/com/coder/toolbox/util/Without.kt

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)