Skip to content

Commit 1a3415b

Browse files
committed
impl: setup auth manager with auth and token endpoints
Toolbox API comes with a basic oauth2 client. This commit sets-up details about two important oauth flows: - authorization flow, in which the user is sent to web page where an authorization code is generated which is exchanged for an access token. - details about token refresh endpoint where users can obtain a new access token and a new refresh token. A couple of important aspects: - the client app id is resolved in upstream - as well as the actual endpoints for authorization and token refresh - S256 is the only code challenge supported
1 parent 8bfee5e commit 1a3415b

File tree

5 files changed

+130
-0
lines changed

5 files changed

+130
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.coder.toolbox.oauth
2+
3+
import com.squareup.moshi.Json
4+
import com.squareup.moshi.JsonClass
5+
6+
@JsonClass(generateAdapter = true)
7+
data class AuthorizationServer(
8+
@field:Json(name = "authorization_endpoint") val authorizationEndpoint: String,
9+
@field:Json(name = "token_endpoint") val tokenEndpoint: String,
10+
@property:Json(name = "token_endpoint_auth_methods_supported") val authMethodForTokenEndpoint: List<TokenEndpointAuthMethod>,
11+
)
12+
13+
enum class TokenEndpointAuthMethod {
14+
@Json(name = "none")
15+
NONE,
16+
17+
@Json(name = "client_secret_post")
18+
CLIENT_SECRET_POST,
19+
20+
@Json(name = "client_secret_basic")
21+
CLIENT_SECRET_BASIC,
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.coder.toolbox.oauth
2+
3+
import com.jetbrains.toolbox.api.core.auth.Account
4+
5+
data class CoderAccount(override val id: String, override val fullName: String) : Account
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.coder.toolbox.oauth
2+
3+
import com.coder.toolbox.util.toBaseURL
4+
import com.jetbrains.toolbox.api.core.auth.AuthConfiguration
5+
import com.jetbrains.toolbox.api.core.auth.ContentType
6+
import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED
7+
import com.jetbrains.toolbox.api.core.auth.OAuthToken
8+
import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface
9+
import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration
10+
11+
class CoderOAuthManager(
12+
private val clientId: String,
13+
private val authServer: AuthorizationServer
14+
) : PluginAuthInterface<CoderAccount, CoderLoginCfg> {
15+
override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}"
16+
17+
override fun deserialize(string: String): CoderAccount = CoderAccount(
18+
string.split('|')[0],
19+
string.split('|')[1]
20+
)
21+
22+
override suspend fun createAccount(
23+
token: OAuthToken,
24+
config: AuthConfiguration
25+
): CoderAccount {
26+
TODO("Not yet implemented")
27+
}
28+
29+
override suspend fun updateAccount(
30+
token: OAuthToken,
31+
account: CoderAccount
32+
): CoderAccount {
33+
TODO("Not yet implemented")
34+
}
35+
36+
override fun createAuthConfig(loginConfiguration: CoderLoginCfg): AuthConfiguration = AuthConfiguration(
37+
authParams = mapOf("response_type" to "code", "client_id" to clientId),
38+
tokenParams = mapOf("grant_type" to "authorization_code", "client_id" to clientId),
39+
baseUrl = authServer.authorizationEndpoint.toBaseURL().toString(),
40+
authUrl = authServer.authorizationEndpoint,
41+
tokenUrl = authServer.tokenEndpoint,
42+
codeChallengeParamName = "code_challenge",
43+
codeChallengeMethod = "S256",
44+
verifierParamName = "code_verifier",
45+
authorization = null
46+
)
47+
48+
49+
override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration {
50+
return object : RefreshConfiguration {
51+
override val refreshUrl: String = authServer.tokenEndpoint
52+
override val parameters: Map<String, String> =
53+
mapOf("grant_type" to "refresh_token", "client_id" to clientId)
54+
override val authorization: String? = null
55+
override val contentType: ContentType = FORM_URL_ENCODED
56+
}
57+
}
58+
}
59+
60+
object CoderLoginCfg

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import java.net.URL
88

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

11+
fun String.toBaseURL(): URL {
12+
val url = this.toURL()
13+
val port = if (url.port != -1) ":${url.port}" else ""
14+
return URI.create("${url.protocol}://${url.host}$port").toURL()
15+
}
16+
1117
fun String.validateStrictWebUrl(): WebUrlValidationResult = try {
1218
val uri = URI(this)
1319

@@ -21,15 +27,18 @@ fun String.validateStrictWebUrl(): WebUrlValidationResult = try {
2127
"The URL \"$this\" is missing a scheme (like https://). " +
2228
"Please enter a full web address like \"https://example.com\""
2329
)
30+
2431
uri.scheme?.lowercase() !in setOf("http", "https") ->
2532
Invalid(
2633
"The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\""
2734
)
35+
2836
uri.authority.isNullOrBlank() ->
2937
Invalid(
3038
"The URL \"$this\" does not include a valid website name. " +
3139
"Please enter a full web address like \"https://example.com\""
3240
)
41+
3342
else -> Valid
3443
}
3544
} catch (_: Exception) {

src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import java.net.URI
44
import java.net.URL
55
import kotlin.test.Test
66
import kotlin.test.assertEquals
7+
import kotlin.test.assertFailsWith
78

89
internal class URLExtensionsTest {
910
@Test
@@ -152,4 +153,37 @@ internal class URLExtensionsTest {
152153
result
153154
)
154155
}
156+
157+
@Test
158+
fun `returns base URL without path or query`() {
159+
val fullUrl = "https://example.com/path/to/page?param=1"
160+
val result = fullUrl.toBaseURL()
161+
assertEquals(URL("https://example.com"), result)
162+
}
163+
164+
@Test
165+
fun `includes port if specified`() {
166+
val fullUrl = "https://example.com:8080/api/v1/resource"
167+
val result = fullUrl.toBaseURL()
168+
assertEquals(URL("https://example.com:8080"), result)
169+
}
170+
171+
@Test
172+
fun `handles subdomains correctly`() {
173+
val fullUrl = "http://api.subdomain.example.org/v2/users"
174+
val result = fullUrl.toBaseURL()
175+
assertEquals(URL("http://api.subdomain.example.org"), result)
176+
}
177+
178+
@Test
179+
fun `handles simple domain without path`() {
180+
val fullUrl = "https://test.com"
181+
val result = fullUrl.toBaseURL()
182+
assertEquals(URL("https://test.com"), result)
183+
}
184+
185+
@Test
186+
fun `throws exception for invalid URL`() {
187+
assertFailsWith<IllegalArgumentException> { "ht!tp://bad_url".toBaseURL() }
188+
}
155189
}

0 commit comments

Comments
 (0)