Skip to content

Commit

Permalink
Merge pull request #699 from modelix/MODELIX-785-use-auth-token-in-js…
Browse files Browse the repository at this point in the history
…-client

feat(model-client): make Bearer authentication usable from the model client in JavaScript
  • Loading branch information
odzhychko authored Jul 4, 2024
2 parents 3725bfc + 6737bba commit 89911e8
Show file tree
Hide file tree
Showing 17 changed files with 448 additions and 55 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
.gradle/
/build/
/*/build/
**/build
/*/ignite/
.DS_Store
.gradletasknamecache
Expand Down
1 change: 1 addition & 0 deletions model-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions model-client/integration-tests/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
5 changes: 5 additions & 0 deletions model-client/integration-tests/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
mockserver:
image: mockserver/mockserver:5.15.0
ports:
- 55212:1080
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ClientRequestException> {
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<ClientRequestException> {
modelClient.init()
}

assertEquals(HttpStatusCode.Forbidden, exception.response.status)
}
}
Original file line number Diff line number Diff line change
@@ -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<ClientRequestException> {
connectClient(AuthTestFixture.MODEL_SERVER_URL).await()
}

assertEquals(HttpStatusCode.Forbidden, exception.response.status)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -508,7 +509,7 @@ abstract class ModelClientV2Builder {
return this
}

fun authToken(provider: () -> String?): ModelClientV2Builder {
fun authToken(provider: suspend () -> String?): ModelClientV2Builder {
authTokenProvider = provider
return this
}
Expand Down Expand Up @@ -553,6 +554,7 @@ abstract class ModelClientV2Builder {
}
}
}
ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider)
}
}

Expand Down
Loading

0 comments on commit 89911e8

Please sign in to comment.