diff --git a/.gitignore b/.gitignore index fef1788ef2..ba615f925d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .gradle/ -/build/ -/*/build/ +**/build /*/ignite/ .DS_Store .gradletasknamecache diff --git a/model-client/build.gradle.kts b/model-client/build.gradle.kts index 573f148895..29854093cb 100644 --- a/model-client/build.gradle.kts +++ b/model-client/build.gradle.kts @@ -57,6 +57,7 @@ kotlin { implementation(libs.kotlin.logging) implementation(libs.kotlin.datetime) implementation(libs.kotlin.serialization.json) + implementation(libs.ktor.client.auth) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) diff --git a/model-client/integration-tests/build.gradle.kts b/model-client/integration-tests/build.gradle.kts new file mode 100644 index 0000000000..56428b1feb --- /dev/null +++ b/model-client/integration-tests/build.gradle.kts @@ -0,0 +1,68 @@ +// Tests for a model client that cannot run in isolation. +// One such case is starting a server and using the model client from JS at the same time. +// This integration tests start a mock server with Docker Compose. +// +// They are in a subproject so that they can be easily run in isolation or be excluded. +// An alternative to a separate project would be to have a custom compilation. +// I failed to configure custom compilation, and for now, subproject was a more straightforward configuration. +// See https://kotlinlang.org/docs/multiplatform-configure-compilations.html#create-a-custom-compilation +// +// Using docker compose to startup containers with Gradle is not ideal. +// Ideally, each test should do the setup it needs by themselves. +// A good solution would be https://testcontainers.com/. +// But there is no unified Kotlin Multiplatform API and no REST API +// to start containers from web browser executing tests. +// The solution with Docker Compose works for now +// because the number of tests is small and only one container configuration is enough. +plugins { + kotlin("multiplatform") + alias(libs.plugins.docker.compose) +} + +kotlin { + jvm() + js(IR) { + browser { + testTask { + useMocha { + timeout = "30s" + } + } + } + nodejs { + testTask { + useMocha { + timeout = "30s" + } + } + } + useCommonJs() + } + sourceSets { + val commonTest by getting { + dependencies { + implementation(project(":model-client")) + implementation(libs.ktor.client.core) + implementation(libs.kotlin.coroutines.test) + implementation(kotlin("test")) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.ktor.client.cio) + implementation(project(":model-client", configuration = "jvmRuntimeElements")) + } + } + + val jsTest by getting { + dependencies { + implementation(libs.ktor.client.js) + } + } + } +} + +dockerCompose.isRequiredBy(tasks.named("jsBrowserTest")) +dockerCompose.isRequiredBy(tasks.named("jsNodeTest")) +dockerCompose.isRequiredBy(tasks.named("jvmTest")) diff --git a/model-client/integration-tests/docker-compose.yaml b/model-client/integration-tests/docker-compose.yaml new file mode 100644 index 0000000000..f17ec7b993 --- /dev/null +++ b/model-client/integration-tests/docker-compose.yaml @@ -0,0 +1,5 @@ +services: + mockserver: + image: mockserver/mockserver:5.15.0 + ports: + - 55212:1080 diff --git a/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/AuthTestFixture.kt b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/AuthTestFixture.kt new file mode 100644 index 0000000000..7954c7caa9 --- /dev/null +++ b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/AuthTestFixture.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.modelix.model.client2 + +/** + * Common code between tests for authentication tests. + */ +object AuthTestFixture { + const val AUTH_TOKEN = "someToken" + const val MODEL_SERVER_URL: String = "${MockServerUtils.MOCK_SERVER_BASE_URL}/modelClientUsesProvidedAuthToken/v2" + + // language=json + private val POST_CLIENT_ID_WITH_TOKEN_EXPECTATION = """ + { + "httpRequest": { + "method": "POST", + "path": "/modelClientUsesProvidedAuthToken/v2/generate-client-id", + "headers": { + "Authorization": [ + "Bearer $AUTH_TOKEN" + ] + } + }, + "httpResponse": { + "body": { + "type": "STRING", + "string": "3000" + }, + "statusCode": 200 + } + } + """.trimIndent() + + // language=json + private val GET_USER_ID_WITH_TOKEN_EXPECTATION = """ + { + "httpRequest": { + "method": "GET", + "path": "/modelClientUsesProvidedAuthToken/v2/user-id", + "headers": { + "Authorization": [ + "Bearer $AUTH_TOKEN" + ] + } + }, + "httpResponse": { + "body": { + "type": "STRING", + "string": "someUser" + }, + "statusCode": 200 + } + } + """.trimIndent() + + // language=json + private val POST_CLIENT_ID_WITHOUT_TOKEN_EXPECTATION = """ + { + "httpRequest": { + "method": "POST", + "path": "/modelClientUsesProvidedAuthToken/v2/generate-client-id" + }, + "httpResponse": { + "body": { + "type": "STRING", + "string": "Forbidden" + }, + "statusCode": 403 + } + } + """.trimIndent() + + suspend fun addExpectationsForSucceedingAuthenticationWithToken() { + MockServerUtils.clearMockServer() + MockServerUtils.addExpectation(POST_CLIENT_ID_WITH_TOKEN_EXPECTATION) + MockServerUtils.addExpectation(GET_USER_ID_WITH_TOKEN_EXPECTATION) + } + + suspend fun addExpectationsForFailingAuthenticationWithoutToken() { + MockServerUtils.clearMockServer() + MockServerUtils.addExpectation(POST_CLIENT_ID_WITHOUT_TOKEN_EXPECTATION) + } +} diff --git a/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/MockServerUtils.kt b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/MockServerUtils.kt new file mode 100644 index 0000000000..d599995fbb --- /dev/null +++ b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/MockServerUtils.kt @@ -0,0 +1,48 @@ +package org.modelix.model.client2 + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.expectSuccess +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.appendPathSegments +import io.ktor.http.contentType +import io.ktor.http.takeFrom + +/** + * Wrapper to interact with the [mock-server](https://www.mock-server.com) + * started by model-client-integration-tests/docker-compose.yaml + * + * Uses the REST API instead of JS and JAVA SDKs to be usable with multiplatform tests. + * See https://app.swaggerhub.com/apis/jamesdbloom/mock-server-openapi + */ +object MockServerUtils { + // We do not start the mock server on a random port, + // because we have no easy way to pass the port number into this test. + // Reading the port from environment variables in KMP is not straight forward. + // Therefore, a static port was chosen that will probably be free. + const val MOCK_SERVER_BASE_URL = "http://0.0.0.0:55212" + private val httpClient: HttpClient = HttpClient() + + suspend fun clearMockServer() { + httpClient.put { + expectSuccess = true + url { + takeFrom(MOCK_SERVER_BASE_URL) + appendPathSegments("/mockserver/clear") + } + } + } + + suspend fun addExpectation(expectationBody: String) { + httpClient.put { + expectSuccess = true + url { + takeFrom(MOCK_SERVER_BASE_URL) + appendPathSegments("/mockserver/expectation") + } + contentType(ContentType.Application.Json) + setBody(expectationBody) + } + } +} diff --git a/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/ModelClientV2AuthTest.kt b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/ModelClientV2AuthTest.kt new file mode 100644 index 0000000000..f0ddee76ed --- /dev/null +++ b/model-client/integration-tests/src/commonTest/kotlin/org/modelix/model/client2/ModelClientV2AuthTest.kt @@ -0,0 +1,52 @@ +package org.modelix.model.client2 + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ModelClientV2AuthTest { + + @Test + fun modelClientUsesProvidedAuthToken() = runTest { + AuthTestFixture.addExpectationsForSucceedingAuthenticationWithToken() + val modelClient = ModelClientV2.builder() + .url(AuthTestFixture.MODEL_SERVER_URL) + .authToken { AuthTestFixture.AUTH_TOKEN } + .build() + + // Test when the client can initialize itself successfully using the provided token. + modelClient.init() + } + + @Test + fun modelClientFailsWithInitialNullValueForAuthToken() = runTest { + AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken() + val modelClient = ModelClientV2.builder() + .url(AuthTestFixture.MODEL_SERVER_URL) + .authToken { null } + .build() + + val exception = assertFailsWith { + modelClient.init() + } + + assertEquals(HttpStatusCode.Forbidden, exception.response.status) + } + + @Test + fun modelClientFailsWithoutAuthTokenProvider() = runTest { + AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken() + val modelClient = ModelClientV2.builder() + .url(AuthTestFixture.MODEL_SERVER_URL) + .build() + + val exception = assertFailsWith { + modelClient.init() + } + + assertEquals(HttpStatusCode.Forbidden, exception.response.status) + } +} diff --git a/model-client/integration-tests/src/jsTest/kotlin/org/modelix/model/client2/ClientJsAuthTest.kt b/model-client/integration-tests/src/jsTest/kotlin/org/modelix/model/client2/ClientJsAuthTest.kt new file mode 100644 index 0000000000..89338394b1 --- /dev/null +++ b/model-client/integration-tests/src/jsTest/kotlin/org/modelix/model/client2/ClientJsAuthTest.kt @@ -0,0 +1,35 @@ +@file:OptIn(UnstableModelixFeature::class) + +package org.modelix.model.client2 + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.await +import kotlinx.coroutines.test.runTest +import org.modelix.kotlin.utils.UnstableModelixFeature +import kotlin.js.Promise +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ClientJsAuthTest { + + @Test + fun jsClientProvidesAuthToken() = runTest { + AuthTestFixture.addExpectationsForSucceedingAuthenticationWithToken() + + // Test when the client can initialize itself successfully using the provided token. + connectClient(AuthTestFixture.MODEL_SERVER_URL) { Promise.resolve(AuthTestFixture.AUTH_TOKEN) }.await() + } + + @Test + fun jsClientFailsWithoutAuthTokenProvider() = runTest { + AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken() + + val exception = assertFailsWith { + connectClient(AuthTestFixture.MODEL_SERVER_URL).await() + } + + assertEquals(HttpStatusCode.Forbidden, exception.response.status) + } +} diff --git a/model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt b/model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt index f92c22a873..2732cbfcfd 100644 --- a/model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt +++ b/model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt @@ -65,6 +65,7 @@ import org.modelix.model.lazy.IDeserializingKeyValueStore import org.modelix.model.lazy.ObjectStoreCache import org.modelix.model.lazy.RepositoryId import org.modelix.model.lazy.computeDelta +import org.modelix.model.oauth.ModelixAuthClient import org.modelix.model.operations.OTBranch import org.modelix.model.persistent.HashUtil import org.modelix.model.persistent.MapBasedStore @@ -485,7 +486,7 @@ class ModelClientV2( abstract class ModelClientV2Builder { protected var httpClient: HttpClient? = null protected var baseUrl: String = "https://localhost/model/v2" - protected var authTokenProvider: (() -> String?)? = null + protected var authTokenProvider: (suspend () -> String?)? = null protected var userId: String? = null protected var connectTimeout: Duration = 1.seconds protected var requestTimeout: Duration = 30.seconds @@ -508,7 +509,7 @@ abstract class ModelClientV2Builder { return this } - fun authToken(provider: () -> String?): ModelClientV2Builder { + fun authToken(provider: suspend () -> String?): ModelClientV2Builder { authTokenProvider = provider return this } @@ -553,6 +554,7 @@ abstract class ModelClientV2Builder { } } } + ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider) } } diff --git a/model-client/src/commonMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt b/model-client/src/commonMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt new file mode 100644 index 0000000000..efc3e5b4d1 --- /dev/null +++ b/model-client/src/commonMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt @@ -0,0 +1,69 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.modelix.model.oauth + +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer + +/** + * Functions and states for authenticating to a model server. + * Configuration differs for JS and JVM. + */ +expect object ModelixAuthClient { + /** + * Function to configure the authentication for an HTTP client. + * + * If an [authTokenProvider] is given, both implementations use the provided token for Bearer authentication. + * This is stateless. + * + * If no [authTokenProvider] is given, the JS implementation does not configure authentication. + * If no [authTokenProvider] is given, + * the JVM implementation setups a server to perform an OAuth authorization code flow with PKCE. + * This makes many assumptions about the model server deployment, + * Keycloak deployment, Keycloak configuration, and the client. + * The PKCE is hard coded to work for MPS instances inside Modelix workspaces. + * This is stateful. + * + * @param config Config for the HTTP client to be created. + * This config will be modified to enable authentication. + * @param baseUrl Base url of model server. + * Required for PKCE flow in JVM. + * @param authTokenProvider This function will be used to initially get an auth token + * and to refresh it when the old one expired. + * Returning `null` cause the client to attempt the request without a token. + */ + fun installAuth(config: HttpClientConfig<*>, baseUrl: String, authTokenProvider: (suspend () -> String?)? = null) +} + +internal fun installAuthWithAuthTokenProvider(config: HttpClientConfig<*>, authTokenProvider: suspend () -> String?) { + config.apply { + install(Auth) { + bearer { + loadTokens { + authTokenProvider()?.let { authToken -> BearerTokens(authToken, "") } + } + refreshTokens { + val providedToken = authTokenProvider() + if (providedToken != null && providedToken != this.oldTokens?.accessToken) { + BearerTokens(providedToken, "") + } else { + null + } + } + } + } + } +} diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt index 568b9dafba..efe521fd69 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt @@ -22,6 +22,7 @@ import INodeJS import INodeReferenceJS import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.await import kotlinx.coroutines.promise import org.modelix.kotlin.utils.UnstableModelixFeature import org.modelix.model.ModelFacade @@ -30,7 +31,6 @@ import org.modelix.model.api.JSNodeConverter import org.modelix.model.data.ModelData import org.modelix.model.lazy.RepositoryId import org.modelix.model.withAutoTransactions -import kotlin.Unit import kotlin.js.Promise /** @@ -73,9 +73,15 @@ fun loadModelsFromJsonAsBranch(json: Array): BranchJS { intendedFinalization = "The client is intended to be finalized when the overarching task is finished.", ) @JsExport -fun connectClient(url: String): Promise { +fun connectClient(url: String, bearerTokenProvider: (() -> Promise)? = null): Promise { return GlobalScope.promise { - val client = ModelClientV2.builder().url(url).build() + val clientBuilder = ModelClientV2.builder() + .url(url) + + if (bearerTokenProvider != null) { + clientBuilder.authToken { bearerTokenProvider().await() } + } + val client = clientBuilder.build() client.init() return@promise ClientJSImpl(client) } diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt index b0f6c949db..debd8aede4 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt @@ -1,7 +1,6 @@ package org.modelix.model.client2 import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.engine.js.Js actual class ModelClientV2PlatformSpecificBuilder : ModelClientV2Builder() { @@ -10,8 +9,4 @@ actual class ModelClientV2PlatformSpecificBuilder : ModelClientV2Builder() { configureHttpClient(this) } } - - override fun configureHttpClient(config: HttpClientConfig<*>) { - super.configureHttpClient(config) - } } diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt b/model-client/src/jsMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt new file mode 100644 index 0000000000..3ed878d3c2 --- /dev/null +++ b/model-client/src/jsMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.modelix.model.oauth + +import io.ktor.client.HttpClientConfig + +@Suppress("UndocumentedPublicClass") // already documented in the expected declaration +actual object ModelixAuthClient { + @Suppress("UndocumentedPublicFunction") // already documented in the expected declaration + actual fun installAuth(config: HttpClientConfig<*>, baseUrl: String, authTokenProvider: (suspend () -> String?)?) { + if (authTokenProvider != null) { + installAuthWithAuthTokenProvider(config, authTokenProvider) + } + } +} diff --git a/model-client/src/jvmMain/kotlin/org/modelix/model/client/RestWebModelClient.kt b/model-client/src/jvmMain/kotlin/org/modelix/model/client/RestWebModelClient.kt index 0731f2fdb6..e062ac4fec 100644 --- a/model-client/src/jvmMain/kotlin/org/modelix/model/client/RestWebModelClient.kt +++ b/model-client/src/jvmMain/kotlin/org/modelix/model/client/RestWebModelClient.kt @@ -60,7 +60,7 @@ import org.modelix.model.api.IIdGenerator import org.modelix.model.lazy.IDeserializingKeyValueStore import org.modelix.model.lazy.ObjectStoreCache import org.modelix.model.lazy.PrefetchCache -import org.modelix.model.oauth.ModelixOAuthClient +import org.modelix.model.oauth.ModelixAuthClient import org.modelix.model.persistent.HashUtil import org.modelix.model.sleep import org.modelix.model.util.StreamUtils.toStream @@ -184,7 +184,7 @@ class RestWebModelClient @JvmOverloads constructor( loadTokens { val tp = authTokenProvider if (tp == null) { - ModelixOAuthClient.getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) } + ModelixAuthClient.getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) } } else { val token = tp() if (token == null) { @@ -200,9 +200,10 @@ class RestWebModelClient @JvmOverloads constructor( if (tp == null) { var url = baseUrl if (!url.endsWith("/")) url += "/" + // TODO MODELIX-975 See ModelixOAuthClient.installAuthWithPKCEFlow if (url.endsWith("/model/")) url = url.substringBeforeLast("/model/") connectionStatus = ConnectionStatus.WAITING_FOR_TOKEN - val tokens = ModelixOAuthClient.authorize(url) + val tokens = ModelixAuthClient.authorize(url) BearerTokens(tokens.accessToken, tokens.refreshToken) } else { val providedToken = tp() diff --git a/model-client/src/jvmMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt b/model-client/src/jvmMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt index f89016dd51..03870b6662 100644 --- a/model-client/src/jvmMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt +++ b/model-client/src/jvmMain/kotlin/org/modelix/model/client2/ModelClientV2PlatformSpecificBuilder.kt @@ -1,18 +1,9 @@ package org.modelix.model.client2 import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.engine.cio.CIO -import org.modelix.model.oauth.ModelixOAuthClient actual class ModelClientV2PlatformSpecificBuilder : ModelClientV2Builder() { - override fun configureHttpClient(config: HttpClientConfig<*>) { - super.configureHttpClient(config) - config.apply { - ModelixOAuthClient.installAuth(this, baseUrl, authTokenProvider) - } - } - actual override fun createHttpClient(): HttpClient { return HttpClient(CIO) { configureHttpClient(this) diff --git a/model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixOAuthClient.kt b/model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt similarity index 64% rename from model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixOAuthClient.kt rename to model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt index 620c58af7b..3f7c0237bd 100644 --- a/model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixOAuthClient.kt +++ b/model-client/src/jvmMain/kotlin/org/modelix/model/oauth/ModelixAuthClient.kt @@ -34,7 +34,8 @@ import io.ktor.client.plugins.auth.providers.bearer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -object ModelixOAuthClient { +@Suppress("UndocumentedPublicClass") // already documented in the expected declaration +actual object ModelixAuthClient { private var DATA_STORE_FACTORY: DataStoreFactory = MemoryDataStoreFactory() private val SCOPE = "email" private val HTTP_TRANSPORT: HttpTransport = NetHttpTransport() @@ -66,42 +67,35 @@ object ModelixOAuthClient { } } - fun installAuth(config: HttpClientConfig<*>, baseUrl: String, authTokenProvider: (() -> String?)? = null) { + @Suppress("UndocumentedPublicFunction") // already documented in the expected declaration + actual fun installAuth(config: HttpClientConfig<*>, baseUrl: String, authTokenProvider: (suspend () -> String?)?) { + if (authTokenProvider != null) { + installAuthWithAuthTokenProvider(config, authTokenProvider) + } else { + installAuthWithPKCEFlow(config, baseUrl) + } + } + + private fun installAuthWithPKCEFlow(config: HttpClientConfig<*>, baseUrl: String) { config.apply { install(Auth) { bearer { loadTokens { - val tp = authTokenProvider - if (tp == null) { - ModelixOAuthClient.getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) } - } else { - val token = tp() - if (token == null) { -// connectionStatus = RestWebModelClient.ConnectionStatus.WAITING_FOR_TOKEN - null - } else { - BearerTokens(token, "") - } - } + getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) } } refreshTokens { - val tp = authTokenProvider - if (tp == null) { - var url = baseUrl - if (!url.endsWith("/")) url += "/" - if (url.endsWith("/model/")) url = url.substringBeforeLast("/model/") -// connectionStatus = RestWebModelClient.ConnectionStatus.WAITING_FOR_TOKEN - val tokens = ModelixOAuthClient.authorize(url) - BearerTokens(tokens.accessToken, tokens.refreshToken) - } else { - val providedToken = tp() - if (providedToken != null && providedToken != this.oldTokens?.accessToken) { - BearerTokens(providedToken, "") - } else { -// connectionStatus = RestWebModelClient.ConnectionStatus.WAITING_FOR_TOKEN - null - } - } + var url = baseUrl + if (!url.endsWith("/")) url += "/" + // XXX Detecting and removing "/model/" is workaround for when the model server + // is used in Modelix workspaces and reachable behind the sub path /model/". + // When the model server is reachable at https://example.org/model/, + // Keycloak is expected to be reachable under https://example.org/realms/ + // See https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L14 + // and https://github.com/modelix/modelix.kubernetes/blob/60f7db6533c3fb82209b1a6abb6836923f585672/proxy/nginx.conf#L41 + // TODO MODELIX-975 remove this check and replace with configuration. + if (url.endsWith("/model/")) url = url.substringBeforeLast("/model/") + val tokens = authorize(url) + BearerTokens(tokens.accessToken, tokens.refreshToken) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e70d3fe527..6d76b07ac3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include("model-api-gen") include("model-api-gen-gradle") include("model-api-gen-runtime") include("model-client") +include("model-client:integration-tests") include("model-datastructure") include("model-server") include("model-server-api")