Skip to content

Commit 89911e8

Browse files
author
Oleksandr Dzhychko
authored
Merge pull request #699 from modelix/MODELIX-785-use-auth-token-in-js-client
feat(model-client): make Bearer authentication usable from the model client in JavaScript
2 parents 3725bfc + 6737bba commit 89911e8

File tree

17 files changed

+448
-55
lines changed

17 files changed

+448
-55
lines changed

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
.gradle/
2-
/build/
3-
/*/build/
2+
**/build
43
/*/ignite/
54
.DS_Store
65
.gradletasknamecache

model-client/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ kotlin {
5757
implementation(libs.kotlin.logging)
5858
implementation(libs.kotlin.datetime)
5959
implementation(libs.kotlin.serialization.json)
60+
implementation(libs.ktor.client.auth)
6061
implementation(libs.ktor.client.core)
6162
implementation(libs.ktor.client.content.negotiation)
6263
implementation(libs.ktor.serialization.json)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Tests for a model client that cannot run in isolation.
2+
// One such case is starting a server and using the model client from JS at the same time.
3+
// This integration tests start a mock server with Docker Compose.
4+
//
5+
// They are in a subproject so that they can be easily run in isolation or be excluded.
6+
// An alternative to a separate project would be to have a custom compilation.
7+
// I failed to configure custom compilation, and for now, subproject was a more straightforward configuration.
8+
// See https://kotlinlang.org/docs/multiplatform-configure-compilations.html#create-a-custom-compilation
9+
//
10+
// Using docker compose to startup containers with Gradle is not ideal.
11+
// Ideally, each test should do the setup it needs by themselves.
12+
// A good solution would be https://testcontainers.com/.
13+
// But there is no unified Kotlin Multiplatform API and no REST API
14+
// to start containers from web browser executing tests.
15+
// The solution with Docker Compose works for now
16+
// because the number of tests is small and only one container configuration is enough.
17+
plugins {
18+
kotlin("multiplatform")
19+
alias(libs.plugins.docker.compose)
20+
}
21+
22+
kotlin {
23+
jvm()
24+
js(IR) {
25+
browser {
26+
testTask {
27+
useMocha {
28+
timeout = "30s"
29+
}
30+
}
31+
}
32+
nodejs {
33+
testTask {
34+
useMocha {
35+
timeout = "30s"
36+
}
37+
}
38+
}
39+
useCommonJs()
40+
}
41+
sourceSets {
42+
val commonTest by getting {
43+
dependencies {
44+
implementation(project(":model-client"))
45+
implementation(libs.ktor.client.core)
46+
implementation(libs.kotlin.coroutines.test)
47+
implementation(kotlin("test"))
48+
}
49+
}
50+
51+
val jvmTest by getting {
52+
dependencies {
53+
implementation(libs.ktor.client.cio)
54+
implementation(project(":model-client", configuration = "jvmRuntimeElements"))
55+
}
56+
}
57+
58+
val jsTest by getting {
59+
dependencies {
60+
implementation(libs.ktor.client.js)
61+
}
62+
}
63+
}
64+
}
65+
66+
dockerCompose.isRequiredBy(tasks.named("jsBrowserTest"))
67+
dockerCompose.isRequiredBy(tasks.named("jsNodeTest"))
68+
dockerCompose.isRequiredBy(tasks.named("jvmTest"))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
services:
2+
mockserver:
3+
image: mockserver/mockserver:5.15.0
4+
ports:
5+
- 55212:1080
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) 2024.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.modelix.model.client2
18+
19+
/**
20+
* Common code between tests for authentication tests.
21+
*/
22+
object AuthTestFixture {
23+
const val AUTH_TOKEN = "someToken"
24+
const val MODEL_SERVER_URL: String = "${MockServerUtils.MOCK_SERVER_BASE_URL}/modelClientUsesProvidedAuthToken/v2"
25+
26+
// language=json
27+
private val POST_CLIENT_ID_WITH_TOKEN_EXPECTATION = """
28+
{
29+
"httpRequest": {
30+
"method": "POST",
31+
"path": "/modelClientUsesProvidedAuthToken/v2/generate-client-id",
32+
"headers": {
33+
"Authorization": [
34+
"Bearer $AUTH_TOKEN"
35+
]
36+
}
37+
},
38+
"httpResponse": {
39+
"body": {
40+
"type": "STRING",
41+
"string": "3000"
42+
},
43+
"statusCode": 200
44+
}
45+
}
46+
""".trimIndent()
47+
48+
// language=json
49+
private val GET_USER_ID_WITH_TOKEN_EXPECTATION = """
50+
{
51+
"httpRequest": {
52+
"method": "GET",
53+
"path": "/modelClientUsesProvidedAuthToken/v2/user-id",
54+
"headers": {
55+
"Authorization": [
56+
"Bearer $AUTH_TOKEN"
57+
]
58+
}
59+
},
60+
"httpResponse": {
61+
"body": {
62+
"type": "STRING",
63+
"string": "someUser"
64+
},
65+
"statusCode": 200
66+
}
67+
}
68+
""".trimIndent()
69+
70+
// language=json
71+
private val POST_CLIENT_ID_WITHOUT_TOKEN_EXPECTATION = """
72+
{
73+
"httpRequest": {
74+
"method": "POST",
75+
"path": "/modelClientUsesProvidedAuthToken/v2/generate-client-id"
76+
},
77+
"httpResponse": {
78+
"body": {
79+
"type": "STRING",
80+
"string": "Forbidden"
81+
},
82+
"statusCode": 403
83+
}
84+
}
85+
""".trimIndent()
86+
87+
suspend fun addExpectationsForSucceedingAuthenticationWithToken() {
88+
MockServerUtils.clearMockServer()
89+
MockServerUtils.addExpectation(POST_CLIENT_ID_WITH_TOKEN_EXPECTATION)
90+
MockServerUtils.addExpectation(GET_USER_ID_WITH_TOKEN_EXPECTATION)
91+
}
92+
93+
suspend fun addExpectationsForFailingAuthenticationWithoutToken() {
94+
MockServerUtils.clearMockServer()
95+
MockServerUtils.addExpectation(POST_CLIENT_ID_WITHOUT_TOKEN_EXPECTATION)
96+
}
97+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.modelix.model.client2
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.expectSuccess
5+
import io.ktor.client.request.put
6+
import io.ktor.client.request.setBody
7+
import io.ktor.http.ContentType
8+
import io.ktor.http.appendPathSegments
9+
import io.ktor.http.contentType
10+
import io.ktor.http.takeFrom
11+
12+
/**
13+
* Wrapper to interact with the [mock-server](https://www.mock-server.com)
14+
* started by model-client-integration-tests/docker-compose.yaml
15+
*
16+
* Uses the REST API instead of JS and JAVA SDKs to be usable with multiplatform tests.
17+
* See https://app.swaggerhub.com/apis/jamesdbloom/mock-server-openapi
18+
*/
19+
object MockServerUtils {
20+
// We do not start the mock server on a random port,
21+
// because we have no easy way to pass the port number into this test.
22+
// Reading the port from environment variables in KMP is not straight forward.
23+
// Therefore, a static port was chosen that will probably be free.
24+
const val MOCK_SERVER_BASE_URL = "http://0.0.0.0:55212"
25+
private val httpClient: HttpClient = HttpClient()
26+
27+
suspend fun clearMockServer() {
28+
httpClient.put {
29+
expectSuccess = true
30+
url {
31+
takeFrom(MOCK_SERVER_BASE_URL)
32+
appendPathSegments("/mockserver/clear")
33+
}
34+
}
35+
}
36+
37+
suspend fun addExpectation(expectationBody: String) {
38+
httpClient.put {
39+
expectSuccess = true
40+
url {
41+
takeFrom(MOCK_SERVER_BASE_URL)
42+
appendPathSegments("/mockserver/expectation")
43+
}
44+
contentType(ContentType.Application.Json)
45+
setBody(expectationBody)
46+
}
47+
}
48+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.modelix.model.client2
2+
3+
import io.ktor.client.plugins.ClientRequestException
4+
import io.ktor.http.HttpStatusCode
5+
import kotlinx.coroutines.test.runTest
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFailsWith
9+
10+
class ModelClientV2AuthTest {
11+
12+
@Test
13+
fun modelClientUsesProvidedAuthToken() = runTest {
14+
AuthTestFixture.addExpectationsForSucceedingAuthenticationWithToken()
15+
val modelClient = ModelClientV2.builder()
16+
.url(AuthTestFixture.MODEL_SERVER_URL)
17+
.authToken { AuthTestFixture.AUTH_TOKEN }
18+
.build()
19+
20+
// Test when the client can initialize itself successfully using the provided token.
21+
modelClient.init()
22+
}
23+
24+
@Test
25+
fun modelClientFailsWithInitialNullValueForAuthToken() = runTest {
26+
AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken()
27+
val modelClient = ModelClientV2.builder()
28+
.url(AuthTestFixture.MODEL_SERVER_URL)
29+
.authToken { null }
30+
.build()
31+
32+
val exception = assertFailsWith<ClientRequestException> {
33+
modelClient.init()
34+
}
35+
36+
assertEquals(HttpStatusCode.Forbidden, exception.response.status)
37+
}
38+
39+
@Test
40+
fun modelClientFailsWithoutAuthTokenProvider() = runTest {
41+
AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken()
42+
val modelClient = ModelClientV2.builder()
43+
.url(AuthTestFixture.MODEL_SERVER_URL)
44+
.build()
45+
46+
val exception = assertFailsWith<ClientRequestException> {
47+
modelClient.init()
48+
}
49+
50+
assertEquals(HttpStatusCode.Forbidden, exception.response.status)
51+
}
52+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@file:OptIn(UnstableModelixFeature::class)
2+
3+
package org.modelix.model.client2
4+
5+
import io.ktor.client.plugins.ClientRequestException
6+
import io.ktor.http.HttpStatusCode
7+
import kotlinx.coroutines.await
8+
import kotlinx.coroutines.test.runTest
9+
import org.modelix.kotlin.utils.UnstableModelixFeature
10+
import kotlin.js.Promise
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertFailsWith
14+
15+
class ClientJsAuthTest {
16+
17+
@Test
18+
fun jsClientProvidesAuthToken() = runTest {
19+
AuthTestFixture.addExpectationsForSucceedingAuthenticationWithToken()
20+
21+
// Test when the client can initialize itself successfully using the provided token.
22+
connectClient(AuthTestFixture.MODEL_SERVER_URL) { Promise.resolve(AuthTestFixture.AUTH_TOKEN) }.await()
23+
}
24+
25+
@Test
26+
fun jsClientFailsWithoutAuthTokenProvider() = runTest {
27+
AuthTestFixture.addExpectationsForFailingAuthenticationWithoutToken()
28+
29+
val exception = assertFailsWith<ClientRequestException> {
30+
connectClient(AuthTestFixture.MODEL_SERVER_URL).await()
31+
}
32+
33+
assertEquals(HttpStatusCode.Forbidden, exception.response.status)
34+
}
35+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import org.modelix.model.lazy.IDeserializingKeyValueStore
6565
import org.modelix.model.lazy.ObjectStoreCache
6666
import org.modelix.model.lazy.RepositoryId
6767
import org.modelix.model.lazy.computeDelta
68+
import org.modelix.model.oauth.ModelixAuthClient
6869
import org.modelix.model.operations.OTBranch
6970
import org.modelix.model.persistent.HashUtil
7071
import org.modelix.model.persistent.MapBasedStore
@@ -485,7 +486,7 @@ class ModelClientV2(
485486
abstract class ModelClientV2Builder {
486487
protected var httpClient: HttpClient? = null
487488
protected var baseUrl: String = "https://localhost/model/v2"
488-
protected var authTokenProvider: (() -> String?)? = null
489+
protected var authTokenProvider: (suspend () -> String?)? = null
489490
protected var userId: String? = null
490491
protected var connectTimeout: Duration = 1.seconds
491492
protected var requestTimeout: Duration = 30.seconds
@@ -508,7 +509,7 @@ abstract class ModelClientV2Builder {
508509
return this
509510
}
510511

511-
fun authToken(provider: () -> String?): ModelClientV2Builder {
512+
fun authToken(provider: suspend () -> String?): ModelClientV2Builder {
512513
authTokenProvider = provider
513514
return this
514515
}
@@ -553,6 +554,7 @@ abstract class ModelClientV2Builder {
553554
}
554555
}
555556
}
557+
ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider)
556558
}
557559
}
558560

0 commit comments

Comments
 (0)