Skip to content

Commit 5ab070b

Browse files
committed
feat(model-client): more OAuth2 configuration options (client ID, secret, scopes)
1 parent 0d85f9f commit 5ab070b

File tree

11 files changed

+218
-106
lines changed

11 files changed

+218
-106
lines changed

model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ import org.modelix.model.lazy.IDeserializingKeyValueStore
5353
import org.modelix.model.lazy.ObjectStoreCache
5454
import org.modelix.model.lazy.RepositoryId
5555
import org.modelix.model.lazy.computeDelta
56+
import org.modelix.model.oauth.IAuthConfig
57+
import org.modelix.model.oauth.IAuthRequestHandler
5658
import org.modelix.model.oauth.ModelixAuthClient
59+
import org.modelix.model.oauth.OAuthConfig
60+
import org.modelix.model.oauth.OAuthConfigBuilder
61+
import org.modelix.model.oauth.TokenProvider
62+
import org.modelix.model.oauth.TokenProviderAuthConfig
5763
import org.modelix.model.operations.OTBranch
5864
import org.modelix.model.persistent.HashUtil
5965
import org.modelix.model.persistent.MapBasedStore
@@ -491,9 +497,7 @@ class ModelClientV2(
491497
abstract class ModelClientV2Builder {
492498
protected var httpClient: HttpClient? = null
493499
protected var baseUrl: String = "https://localhost/model/v2"
494-
protected var authTokenProvider: (suspend () -> String?)? = null
495-
protected var authRequestBrowser: ((url: String) -> Unit)? = null
496-
protected var oauthEnabled = false
500+
protected var authConfig: IAuthConfig? = null
497501
protected var userId: String? = null
498502
protected var connectTimeout: Duration = 1.seconds
499503
protected var requestTimeout: Duration = 30.seconds
@@ -519,18 +523,70 @@ abstract class ModelClientV2Builder {
519523
return this
520524
}
521525

522-
fun authToken(provider: suspend () -> String?): ModelClientV2Builder {
523-
authTokenProvider = provider
524-
return this
526+
fun authToken(provider: TokenProvider) = also {
527+
authConfig = TokenProviderAuthConfig(provider)
528+
}
529+
530+
fun authConfig(config: IAuthConfig) = also {
531+
this.authConfig = config
525532
}
526533

527-
fun authRequestBrowser(browser: ((url: String) -> Unit)?) = also {
528-
authRequestBrowser = browser
529-
enableOAuth()
534+
fun authRequestBrowser(browser: ((url: String) -> Unit)?) = oauth {
535+
authRequestHandler(
536+
if (browser == null) {
537+
null
538+
} else {
539+
object : IAuthRequestHandler {
540+
override fun browse(url: String) {
541+
browser(url)
542+
}
543+
}
544+
},
545+
)
546+
}
547+
548+
fun enableOAuth() = oauth { }
549+
550+
fun oauth(body: OAuthConfigBuilder.() -> Unit) = also {
551+
authConfig = OAuthConfigBuilder(authConfig as? OAuthConfig ?: legacyOAuthConfig()).apply(body).build()
530552
}
531553

532-
fun enableOAuth() = also {
533-
oauthEnabled = true
554+
/**
555+
* Handles the case when the model-server runs inside a kubernetes cluster with keycloak but doesn't provide the
556+
* OAuth endpoints in the 401 response headers.
557+
*/
558+
private fun legacyOAuthConfig(): OAuthConfig? {
559+
// When the model server is reachable at https://example.org/model/,
560+
// Keycloak is expected to be reachable under https://example.org/realms/
561+
// See https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L14
562+
// and https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L41
563+
564+
val normalizedModelUrl = buildUrl {
565+
takeFrom(baseUrl)
566+
if (pathSegments.lastOrNull() == "") pathSegments = pathSegments.dropLast(1)
567+
if (pathSegments.lastOrNull() == "v2") pathSegments = pathSegments.dropLast(1)
568+
}
569+
if (normalizedModelUrl.segments.lastOrNull() != "model") return null
570+
571+
val oidcUrl = buildUrl {
572+
takeFrom(normalizedModelUrl)
573+
if (pathSegments.lastOrNull() == "model") pathSegments = pathSegments.dropLast(1)
574+
appendPathSegments("realms", "modelix", "protocol", "openid-connect")
575+
}
576+
val authUrl = buildUrl {
577+
takeFrom(oidcUrl)
578+
appendPathSegments("auth")
579+
}
580+
val tokenUrl = buildUrl {
581+
takeFrom(oidcUrl)
582+
appendPathSegments("token")
583+
}
584+
return OAuthConfig(
585+
clientId = "external-mps",
586+
scopes = setOf("email"),
587+
authorizationUrl = authUrl.toString(),
588+
tokenUrl = tokenUrl.toString(),
589+
)
534590
}
535591

536592
fun userId(userId: String?): ModelClientV2Builder {
@@ -585,9 +641,7 @@ abstract class ModelClientV2Builder {
585641
}
586642
}
587643
}
588-
if (authTokenProvider != null || oauthEnabled) {
589-
ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider, authRequestBrowser)
590-
}
644+
authConfig?.let { ModelixAuthClient.installAuth(this, it) }
591645
}
592646
}
593647

model-client/src/commonMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,14 @@ expect object ModelixAuthClient {
1313
/**
1414
* Function to configure the authentication for an HTTP client.
1515
*
16-
* If an [authTokenProvider] is given, both implementations use the provided token for Bearer authentication.
17-
* This is stateless.
18-
*
19-
* If no [authTokenProvider] is given, the JS implementation does not configure authentication.
20-
* If no [authTokenProvider] is given,
21-
* the JVM implementation setups a server to perform an OAuth authorization code flow with PKCE.
22-
* This makes many assumptions about the model server deployment,
23-
* Keycloak deployment, Keycloak configuration, and the client.
24-
* The PKCE is hard coded to work for MPS instances inside Modelix workspaces.
25-
* This is stateful.
26-
*
2716
* @param config Config for the HTTP client to be created.
2817
* This config will be modified to enable authentication.
2918
* @param baseUrl Base url of model server.
3019
* Required for PKCE flow in JVM.
31-
* @param authTokenProvider This function will be used to initially get an auth token
32-
* and to refresh it when the old one expired.
33-
* Returning `null` cause the client to attempt the request without a token.
3420
*/
3521
fun installAuth(
3622
config: HttpClientConfig<*>,
37-
baseUrl: String,
38-
authTokenProvider: (suspend () -> String?)? = null,
39-
authRequestBrowser: ((url: String) -> Unit)? = null,
23+
authConfig: IAuthConfig,
4024
)
4125
}
4226

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package org.modelix.model.oauth
2+
3+
sealed interface IAuthConfig {
4+
companion object {
5+
fun fromTokenProvider(provider: TokenProvider): IAuthConfig {
6+
return TokenProviderAuthConfig(provider)
7+
}
8+
9+
fun oauth(body: OAuthConfigBuilder.() -> Unit): IAuthConfig {
10+
return OAuthConfigBuilder(null).apply(body).build()
11+
}
12+
}
13+
}
14+
15+
class TokenProviderAuthConfig(val provider: TokenProvider) : IAuthConfig
16+
17+
data class OAuthConfig(
18+
val clientId: String? = "external-mps",
19+
val clientSecret: String? = null,
20+
val authorizationUrl: String? = null,
21+
val tokenUrl: String? = null,
22+
val scopes: Set<String> = emptySet(),
23+
val authRequestHandler: IAuthRequestHandler? = null,
24+
) : IAuthConfig
25+
26+
typealias TokenProvider = suspend () -> String?
27+
28+
class OAuthConfigBuilder(initial: OAuthConfig?) {
29+
private var config = initial ?: OAuthConfig()
30+
31+
fun clientId(id: String) = also { config = config.copy(clientId = id) }
32+
fun clientSecret(secret: String) = also { config = config.copy(clientSecret = secret) }
33+
fun scopes(scopes: Iterable<String>) = also { config = config.copy(scopes = scopes.toSet()) }
34+
fun scope(scope: String) = scopes(setOf(scope))
35+
fun additionalScopes(scopes: Iterable<String>) = also { config = config.copy(scopes = config.scopes + scopes) }
36+
fun additionalScope(scope: String) = additionalScopes(setOf(scope))
37+
fun authorizationUrl(url: String) = also { config = config.copy(authorizationUrl = url) }
38+
fun tokenUrl(url: String) = also { config = config.copy(tokenUrl = url) }
39+
fun oidcUrl(url: String) = authorizationUrl(url.trimEnd('/') + "/auth").tokenUrl(url.trimEnd('/') + "/token")
40+
fun authRequestHandler(handler: IAuthRequestHandler?) = also { config = config.copy(authRequestHandler = handler) }
41+
42+
fun build() = config.copy()
43+
}
44+
45+
interface IAuthRequestHandler {
46+
/**
47+
* Open the URL where the user can log in. The OAuth server will redirect the user to a callback URL to transmit
48+
* the authorization code.
49+
*/
50+
fun browse(url: String)
51+
}

model-client/src/jsMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ actual object ModelixAuthClient {
77
@Suppress("UndocumentedPublicFunction") // already documented in the expected declaration
88
actual fun installAuth(
99
config: HttpClientConfig<*>,
10-
baseUrl: String,
11-
authTokenProvider: (suspend () -> String?)?,
12-
authRequestBrowser: ((url: String) -> Unit)?,
10+
authConfig: IAuthConfig,
1311
) {
14-
if (authTokenProvider != null) {
15-
installAuthWithAuthTokenProvider(config, authTokenProvider)
12+
when (authConfig) {
13+
is OAuthConfig -> UnsupportedOperationException("JS client doesn't support OAuth2")
14+
is TokenProviderAuthConfig -> installAuthWithAuthTokenProvider(config, authConfig.provider)
1615
}
1716
}
1817
}

model-client/src/jvmMain/kotlin/org/modelix/model/client/RestWebModelClient.kt

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,7 @@ class RestWebModelClient @JvmOverloads constructor(
166166
refreshTokens {
167167
val tp = authTokenProvider
168168
if (tp == null) {
169-
var url = baseUrl
170-
if (!url.endsWith("/")) url += "/"
171-
// TODO MODELIX-975 See ModelixOAuthClient.installAuthWithPKCEFlow
172-
if (url.endsWith("/model/")) url = url.substringBeforeLast("/model/")
173-
connectionStatus = ConnectionStatus.WAITING_FOR_TOKEN
174-
val tokens = ModelixAuthClient.authorize(url)
175-
BearerTokens(tokens.accessToken, tokens.refreshToken)
169+
null
176170
} else {
177171
val providedToken = tp()
178172
if (providedToken != null && providedToken != this.oldTokens?.accessToken) {

model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt

Lines changed: 44 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@ import com.google.api.client.util.store.MemoryDataStoreFactory
1616
import io.ktor.client.HttpClientConfig
1717
import io.ktor.client.plugins.auth.Auth
1818
import io.ktor.client.plugins.auth.providers.BearerTokens
19+
import io.ktor.client.plugins.auth.providers.RefreshTokensParams
1920
import io.ktor.client.plugins.auth.providers.bearer
21+
import io.ktor.client.statement.request
2022
import io.ktor.http.HttpHeaders
2123
import io.ktor.http.HttpMessage
24+
import io.ktor.http.URLProtocol
25+
import io.ktor.http.Url
2226
import io.ktor.http.auth.HttpAuthHeader
2327
import io.ktor.http.auth.parseAuthorizationHeader
28+
import io.ktor.http.buildUrl
29+
import io.ktor.http.isSecure
30+
import io.ktor.http.takeFrom
2431
import kotlinx.coroutines.Dispatchers
2532
import kotlinx.coroutines.withContext
2633

@@ -45,35 +52,18 @@ actual object ModelixAuthClient {
4552
return this
4653
}
4754

48-
suspend fun authorize(modelixServerUrl: String): Credential {
49-
val oidcUrl = modelixServerUrl.trimEnd('/') + "/realms/modelix/protocol/openid-connect"
50-
return authorize(
51-
clientId = "external-mps",
52-
scopes = listOf("email"),
53-
authUrl = "$oidcUrl/auth",
54-
tokenUrl = "$oidcUrl/token",
55-
authRequestBrowser = null,
56-
)
57-
}
58-
59-
suspend fun authorize(
60-
clientId: String,
61-
scopes: List<String>,
62-
authUrl: String,
63-
tokenUrl: String,
64-
authRequestBrowser: ((url: String) -> Unit)?,
65-
): Credential {
55+
suspend fun authorize(config: OAuthConfig): Credential {
6656
return withContext(Dispatchers.IO) {
6757
val flow = AuthorizationCodeFlow.Builder(
6858
BearerToken.authorizationHeaderAccessMethod(),
6959
HTTP_TRANSPORT,
7060
JSON_FACTORY,
71-
GenericUrl(tokenUrl),
72-
ClientParametersAuthentication(clientId, null),
73-
clientId,
74-
authUrl,
61+
GenericUrl(config.tokenUrl),
62+
ClientParametersAuthentication(config.clientId, config.clientSecret),
63+
config.clientId,
64+
config.authorizationUrl,
7565
)
76-
.setScopes(scopes)
66+
.setScopes(config.scopes)
7767
.enablePKCE()
7868
.setDataStoreFactory(DATA_STORE_FACTORY)
7969
.build()
@@ -82,10 +72,10 @@ actual object ModelixAuthClient {
8272
if (existingTokens?.isExpired() == false) return@withContext existingTokens
8373

8474
val receiver: LocalServerReceiver = LocalServerReceiver.Builder().setHost("127.0.0.1").build()
85-
val browser = authRequestBrowser?.let {
75+
val browser = config.authRequestHandler?.let {
8676
object : AuthorizationCodeInstalledApp.Browser {
8777
override fun browse(url: String) {
88-
it(url)
78+
it.browse(url)
8979
}
9080
}
9181
} ?: AuthorizationCodeInstalledApp.DefaultBrowser()
@@ -100,21 +90,17 @@ actual object ModelixAuthClient {
10090
@Suppress("UndocumentedPublicFunction") // already documented in the expected declaration
10191
actual fun installAuth(
10292
config: HttpClientConfig<*>,
103-
baseUrl: String,
104-
authTokenProvider: (suspend () -> String?)?,
105-
authRequestBrowser: ((url: String) -> Unit)?,
93+
authConfig: IAuthConfig,
10694
) {
107-
if (authTokenProvider != null) {
108-
installAuthWithAuthTokenProvider(config, authTokenProvider)
109-
} else {
110-
installAuthWithPKCEFlow(config, baseUrl, authRequestBrowser)
95+
when (authConfig) {
96+
is TokenProviderAuthConfig -> installAuthWithAuthTokenProvider(config, authConfig.provider)
97+
is OAuthConfig -> installAuthWithPKCEFlow(config, authConfig)
11198
}
11299
}
113100

114101
private fun installAuthWithPKCEFlow(
115102
config: HttpClientConfig<*>,
116-
baseUrl: String,
117-
authRequestBrowser: ((url: String) -> Unit)?,
103+
authConfig: OAuthConfig,
118104
) {
119105
config.apply {
120106
install(Auth) {
@@ -127,32 +113,16 @@ actual object ModelixAuthClient {
127113
// The model server tells the client where to get a token
128114

129115
if (wwwAuthenticate.parameter("error") != "invalid_token") return@let null
130-
val authUrl = wwwAuthenticate.parameter("authorization_uri") ?: return@let null
131-
val tokenUrl = wwwAuthenticate.parameter("token_uri") ?: return@let null
116+
val updatedConfig = authConfig.copy(
117+
authorizationUrl = authConfig.authorizationUrl ?: useSameProtocol(wwwAuthenticate.parameter("authorization_uri") ?: return@let null),
118+
tokenUrl = authConfig.tokenUrl ?: useSameProtocol(wwwAuthenticate.parameter("token_uri") ?: return@let null),
119+
)
132120
val realm = wwwAuthenticate.parameter("realm")
133121
val description = wwwAuthenticate.parameter("error_description")
134-
authorize(
135-
clientId = "modelix-sync-plugin",
136-
scopes = listOf("sync"),
137-
authUrl = authUrl,
138-
tokenUrl = tokenUrl,
139-
authRequestBrowser = authRequestBrowser,
140-
)
141-
} ?: let {
142-
// legacy keycloak specific URLs
122+
authorize(updatedConfig)
123+
} ?: authorize(authConfig)
143124

144-
var url = baseUrl
145-
if (!url.endsWith("/")) url += "/"
146-
// XXX Detecting and removing "/model/" is workaround for when the model server
147-
// is used in Modelix workspaces and reachable behind the sub path /model/".
148-
// When the model server is reachable at https://example.org/model/,
149-
// Keycloak is expected to be reachable under https://example.org/realms/
150-
// See https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L14
151-
// and https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L41
152-
// TODO MODELIX-975 remove this check and replace with configuration.
153-
if (url.endsWith("/model/")) url = url.substringBeforeLast("/model/")
154-
authorize(url)
155-
}
125+
println("Access Token: " + tokens.accessToken)
156126

157127
BearerTokens(tokens.accessToken, tokens.refreshToken)
158128
}
@@ -165,4 +135,21 @@ actual object ModelixAuthClient {
165135
return headers[HttpHeaders.WWWAuthenticate]
166136
?.let { parseAuthorizationHeader(it) as? HttpAuthHeader.Parameterized }
167137
}
138+
139+
/**
140+
* In test environments https often doesn't have a valid certificate.
141+
* Use http for authorization, if the original request itself uses http.
142+
*/
143+
private fun RefreshTokensParams.useSameProtocol(url: String): String {
144+
val needSecureProtocol = response.request.url.protocol.isSecure()
145+
if (Url(url).protocol.isSecure() == needSecureProtocol) return url
146+
return buildUrl {
147+
takeFrom(url)
148+
protocol = when (protocol) {
149+
URLProtocol.HTTPS, URLProtocol.HTTP -> if (needSecureProtocol) URLProtocol.HTTPS else URLProtocol.HTTP
150+
URLProtocol.WSS, URLProtocol.WS -> if (needSecureProtocol) URLProtocol.WSS else URLProtocol.WS
151+
else -> protocol
152+
}
153+
}.toString()
154+
}
168155
}

0 commit comments

Comments
 (0)