Skip to content

Commit

Permalink
fix(model-client): OAuth login
Browse files Browse the repository at this point in the history
  • Loading branch information
slisson committed Feb 28, 2025
1 parent 8a07819 commit c4c6787
Show file tree
Hide file tree
Showing 12 changed files with 745 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import com.nimbusds.jose.jwk.KeyType
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.jwk.source.JWKSource
import com.nimbusds.jose.jwk.source.RemoteJWKSet
Expand Down
11 changes: 11 additions & 0 deletions model-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ kotlin {
implementation(libs.ktor.serialization.json)
}
}
jvmTest {
dependencies {
implementation(libs.logback.classic)
implementation(libs.testcontainers)
}
}
val jsMain by getting {
languageSettings.optIn("kotlin.js.ExperimentalJsExport")
dependencies {
Expand Down Expand Up @@ -156,3 +162,8 @@ npmPublish {
tasks.withType(NodeExecTask::class) {
dependsOn(":setupNodeEverywhere")
}

tasks.jvmTest {
dependsOn(":model-server:assemble")
environment("KEYCLOAK_VERSION", libs.versions.keycloak.get())
}
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ abstract class ModelClientV2Builder {
protected var httpClient: HttpClient? = null
protected var baseUrl: String = "https://localhost/model/v2"
protected var authTokenProvider: (suspend () -> String?)? = null
protected var authRequestBrowser: ((url: String) -> Unit)? = null
protected var userId: String? = null
protected var connectTimeout: Duration = 1.seconds
protected var requestTimeout: Duration = 30.seconds
Expand Down Expand Up @@ -522,6 +523,11 @@ abstract class ModelClientV2Builder {
return this
}

fun authRequestBrowser(browser: ((url: String) -> Unit)?): ModelClientV2Builder {
authRequestBrowser = browser
return this
}

fun userId(userId: String?): ModelClientV2Builder {
this.userId = userId
return this
Expand Down Expand Up @@ -574,7 +580,7 @@ abstract class ModelClientV2Builder {
}
}
}
ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider)
ModelixAuthClient.installAuth(this, baseUrl, authTokenProvider, authRequestBrowser)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ expect object ModelixAuthClient {
* 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)
fun installAuth(
config: HttpClientConfig<*>,
baseUrl: String,
authTokenProvider: (suspend () -> String?)? = null,
authRequestBrowser: ((url: String) -> Unit)? = null,
)
}

internal fun installAuthWithAuthTokenProvider(config: HttpClientConfig<*>, authTokenProvider: suspend () -> String?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ 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?)?) {
actual fun installAuth(
config: HttpClientConfig<*>,
baseUrl: String,
authTokenProvider: (suspend () -> String?)?,
authRequestBrowser: ((url: String) -> Unit)?,
) {
if (authTokenProvider != null) {
installAuthWithAuthTokenProvider(config, authTokenProvider)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ 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
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMessage
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.http.auth.parseAuthorizationHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@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()
private val JSON_FACTORY: JsonFactory = GsonFactory()

Expand All @@ -33,59 +36,117 @@ actual object ModelixAuthClient {
}

suspend fun authorize(modelixServerUrl: String): Credential {
val oidcUrl = modelixServerUrl.trimEnd('/') + "/realms/modelix/protocol/openid-connect"
return authorize(
clientId = "external-mps",
scopes = listOf("email"),
authUrl = "$oidcUrl/auth",
tokenUrl = "$oidcUrl/token",
authRequestBrowser = null,
)
}

suspend fun authorize(
clientId: String,
scopes: List<String>,
authUrl: String,
tokenUrl: String,
authRequestBrowser: ((url: String) -> Unit)?,
): Credential {
return withContext(Dispatchers.IO) {
val oidcUrl = modelixServerUrl.trimEnd('/') + "/realms/modelix/protocol/openid-connect"
val clientId = "external-mps"
val flow = AuthorizationCodeFlow.Builder(
BearerToken.authorizationHeaderAccessMethod(),
HTTP_TRANSPORT,
JSON_FACTORY,
GenericUrl("$oidcUrl/token"),
GenericUrl(tokenUrl),
ClientParametersAuthentication(clientId, null),
clientId,
"$oidcUrl/auth",
authUrl,
)
.setScopes(listOf(SCOPE))
.setScopes(scopes)
.enablePKCE()
.setDataStoreFactory(DATA_STORE_FACTORY)
.build()
val receiver: LocalServerReceiver = LocalServerReceiver.Builder().setHost("127.0.0.1").build()
AuthorizationCodeInstalledApp(flow, receiver).authorize("user")
val browser = authRequestBrowser?.let {
object : AuthorizationCodeInstalledApp.Browser {
override fun browse(url: String) {
it(url)
}
}
} ?: AuthorizationCodeInstalledApp.DefaultBrowser()
AuthorizationCodeInstalledApp(flow, receiver, browser).authorize("user")
}
}

@Suppress("UndocumentedPublicFunction") // already documented in the expected declaration
actual fun installAuth(config: HttpClientConfig<*>, baseUrl: String, authTokenProvider: (suspend () -> String?)?) {
actual fun installAuth(
config: HttpClientConfig<*>,
baseUrl: String,
authTokenProvider: (suspend () -> String?)?,
authRequestBrowser: ((url: String) -> Unit)?,
) {
if (authTokenProvider != null) {
installAuthWithAuthTokenProvider(config, authTokenProvider)
} else {
installAuthWithPKCEFlow(config, baseUrl)
installAuthWithPKCEFlow(config, baseUrl, authRequestBrowser)
}
}

private fun installAuthWithPKCEFlow(config: HttpClientConfig<*>, baseUrl: String) {
private fun installAuthWithPKCEFlow(
config: HttpClientConfig<*>,
baseUrl: String,
authRequestBrowser: ((url: String) -> Unit)?,
) {
config.apply {
install(Auth) {
bearer {
loadTokens {
getTokens()?.let { BearerTokens(it.accessToken, it.refreshToken) }
}
refreshTokens {
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)
val tokens = response.parseWWWAuthenticate()?.let { wwwAuthenticate ->
// The model server tells the client where to get a token

if (wwwAuthenticate.parameter("error") != "invalid_token") return@let null
val authUrl = wwwAuthenticate.parameter("authorization_uri") ?: return@let null
val tokenUrl = wwwAuthenticate.parameter("token_uri") ?: return@let null
val realm = wwwAuthenticate.parameter("realm")
val description = wwwAuthenticate.parameter("error_description")
authorize(
clientId = "modelix-sync-plugin",
scopes = listOf("sync"),
authUrl = authUrl,
tokenUrl = tokenUrl,
authRequestBrowser = authRequestBrowser,
)
} ?: let {
// legacy keycloak specific URLs

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/")
authorize(url)
}

println("Access token: ${tokens.accessToken}")
println("Refresh token: ${tokens.refreshToken}")
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
}
}
}
}

fun HttpMessage.parseWWWAuthenticate(): HttpAuthHeader.Parameterized? {
return headers[HttpHeaders.WWWAuthenticate]
?.let { parseAuthorizationHeader(it) as? HttpAuthHeader.Parameterized }
}
}
126 changes: 126 additions & 0 deletions model-client/src/jvmTest/kotlin/org/modelix/model/client2/OAuthTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package org.modelix.model.client2

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage
import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.parameters
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.modelix.model.api.ITree
import org.modelix.model.lazy.RepositoryId
import org.testcontainers.containers.ComposeContainer
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.Network
import org.testcontainers.images.builder.ImageFromDockerfile
import org.testcontainers.utility.MountableFile
import java.nio.file.Path
import kotlin.io.path.absolute
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.minutes
import kotlin.time.ExperimentalTime

private val modelServerDir = Path.of("../model-server").absolute().normalize()
private val modelServerImage = ImageFromDockerfile()
.withDockerfile(modelServerDir.resolve("Dockerfile"))

class OAuthTest {

@Test
fun test() = runWithModelServer { url ->
val client = ModelClientV2.builder()
.url(url)
.retries(1U)
.authRequestBrowser { authUrl ->
runBlocking {
handleOAuthLogin(authUrl, "user1", "abc")
}
}
.build()
client.init()

val version = client.initRepository(RepositoryId("oauth-test-repo"))
assertEquals(0, version.getTree().getAllChildren(ITree.ROOT_ID).count())
}

private suspend fun handleOAuthLogin(authUrl: String, user: String, password: String) {
val acceptAllCookiesStorage = AcceptAllCookiesStorage()
val cookiesStorage = object : CookiesStorage by acceptAllCookiesStorage {
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
acceptAllCookiesStorage.addCookie(requestUrl, cookie.copy(secure = false))
}
}
val httpClient = HttpClient(CIO) {
install(HttpCookies) {
storage = cookiesStorage
}
}
val html = httpClient.get(authUrl).also { println(it.headers.entries()) }.bodyAsText()

val loginUrl = Regex("""[^"]+/login-actions/authenticate[^"]+""").find(html)!!.value

val callbackUrl = httpClient.submitForm(
url = loginUrl,
formParameters = parameters {
set("username", user)
set("password", password)
},
).headers[HttpHeaders.Location]!!

httpClient.get(callbackUrl).bodyAsText()
}

private fun runWithModelServer(body: suspend (url: String) -> Unit) = runBlocking {
@OptIn(ExperimentalTime::class)
withTimeout(3.minutes) {
val network = Network.newNetwork()

val keycloak: GenericContainer<*> = GenericContainer("quay.io/keycloak/keycloak:${System.getenv("KEYCLOAK_VERSION")}")
.withExposedPorts(8080)
.withCommand("start-dev", "--import-realm")
.withEnv("KEYCLOAK_ADMIN", "admin")
.withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
.withEnv("KC_HTTP_PORT", "8080")
.withEnv("KC_HOSTNAME", "localhost")
.withCopyFileToContainer(MountableFile.forHostPath("../model-server-with-auth/realm.json"), "/opt/keycloak/data/import/realm.json")
.withNetwork(network)
.withNetworkAliases("keycloak")
.withLogConsumer { println("[KEYCLOAK] " + it.utf8StringWithoutLineEnding) }
keycloak.start()
val keycloakPort = keycloak.getMappedPort(8080)

val modelServer: GenericContainer<*> = GenericContainer(modelServerImage)
.withExposedPorts(28101)
.withCommand("--inmemory")
.withEnv("MODELIX_AUTHORIZATION_URI", "http://localhost:$keycloakPort/realms/modelix/protocol/openid-connect/auth")
.withEnv("MODELIX_TOKEN_URI", "http://localhost:$keycloakPort/realms/modelix/protocol/openid-connect/token")
.withEnv("MODELIX_PERMISSION_CHECKS_ENABLED", "true")
.withEnv("MODELIX_JWK_URI_KEYCLOAK", "http://keycloak:8080/realms/modelix/protocol/openid-connect/certs")
.withNetwork(network)
.withNetworkAliases("model-server")
.withLogConsumer { println("[MODEL] " + it.utf8StringWithoutLineEnding) }
modelServer.start()

try {
body("http://localhost:${modelServer.firstMappedPort}/")
} finally {
modelServer.stop()
keycloak.stop()
}
}
}
}

private fun ComposeContainer.getServiceUrl(name: String, port: Int): String {
val h = getServiceHost(name, port)
val p = getServicePort(name, port)
return "http://$h:$p/"
}
21 changes: 21 additions & 0 deletions model-client/src/jvmTest/resources/logback-test.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<configuration debug="true">
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<root level="DEBUG">
<appender-ref ref="console" />
</root>

<!--
Reduce log output crated by testcontainers.
See https://java.testcontainers.org/supported_docker_environment/logging_config/
-->
<logger name="org.testcontainers" level="INFO"/>
<logger name="tc" level="INFO"/>
<logger name="com.github.dockerjava" level="WARN"/>
<logger name="com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire" level="OFF"/>
</configuration>
Loading

0 comments on commit c4c6787

Please sign in to comment.