Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dependencies {
// to decode and validate Keycloak access tokens with user info.
implementation("com.nimbusds:nimbus-jose-jwt:9.39.2")

implementation("org.keycloak:keycloak-authz-client:26.0.7")
implementation(fileTree("libs") { include("*.jar") })

implementation("org.graalvm.polyglot:js:24.2.1")
implementation("org.graalvm.polyglot:polyglot:24.2.1")

Expand Down
Binary file added backend/libs/anylogic-cloud-client-8.5.0.jar
Binary file not shown.
1 change: 1 addition & 0 deletions backend/src/main/kotlin/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ data class Config(
val baseUrl: String = getenv("BASE_URL") ?: "http://localhost:$port",
val clientId: String = getEnvRequired("CLIENT_ID"),
val clientSecret: String = getEnvRequired("CLIENT_SECRET"),
val anylogicApiKey: String = getEnvRequired("ANYLOGIC_API_KEY"),

val smtpConfig: SmtpConfig = SmtpConfig.create(),
val contactMailRecipients: List<String> = getEnvRequired("CONTACT_MAIL_RECIPIENTS")
Expand Down
8 changes: 7 additions & 1 deletion backend/src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.zenmo.backend

import com.zenmo.backend.anylogic.AnyLogicProxy
import com.zenmo.backend.contact.ContactController
import com.zenmo.backend.contact.MailService
import com.zenmo.backend.js.JsServer
import com.zenmo.backend.js.JsServerFilter
import com.zenmo.backend.keycloak.KeycloakAuthClient
import org.http4k.core.HttpHandler
import org.http4k.core.then
import org.http4k.filter.DebuggingFilters
Expand Down Expand Up @@ -32,7 +34,11 @@ fun startServer() {
"/api/contact" bind org.http4k.core.Method.POST to ContactController(MailService.create(config))::handler,
)

val anyLogicProxyRoutes = AnyLogicProxy(oAuthSessions::retrieveIdToken).routes()
val anyLogicProxyRoutes = AnyLogicProxy(
idTokenProvider = oAuthSessions::retrieveIdToken,
accessTokenProvider = oAuthSessions::retrieveToken,
keycloakAuthClient = KeycloakAuthClient()
).routes()

val app: HttpHandler = DebuggingFilters.PrintRequestAndResponse()
.then(corsFilter)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package com.zenmo.backend
package com.zenmo.backend.anylogic

import com.anylogic.cloud.clients.client_8_5_0.AnyLogicCloudClient
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.zenmo.backend.Config
import com.zenmo.backend.keycloak.KeycloakAuthClient
import org.http4k.client.JavaHttpClient
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Uri
import org.http4k.core.*
import org.http4k.format.Jackson
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.path
import org.http4k.security.AccessToken
import org.http4k.security.openid.IdToken


class AnyLogicProxy(
private val idTokenProvider: (Request) -> IdToken?
private val idTokenProvider: (Request) -> IdToken?,
private val accessTokenProvider: (Request) -> AccessToken?,
private val keycloakAuthClient: KeycloakAuthClient,
private val anylogicApiKey: String = Config().anylogicApiKey,
private val anylogicCloudClient: AnyLogicCloudClient = AnyLogicCloudClient(Config().anylogicApiKey)
) {
private val httpClient = JavaHttpClient()
private val anylogicUpstream = Uri.of("https://anylogic.zenmo.com")
Expand All @@ -26,21 +30,56 @@ class AnyLogicProxy(
}

private fun proxyHandler(request: Request): Response {
val path = request.path("path") ?: ""
val path = request.path("path") ?: return Response(Status.BAD_REQUEST).body("Missing path")

val versionId = Regex("api/open/8\\.5\\.0/versions/([^/]+)/runs").find(path)
?.groupValues?.get(1)
?: return Response(Status.BAD_REQUEST).body("Missing version ID in path")

val modelId = resolveModelId(versionId)
?: return Response(Status.BAD_REQUEST).body("Unable to resolve model ID for version $versionId")

val accessToken = accessTokenProvider(request)

// if access token exists, check authorization via Keycloak
if (accessToken != null && !keycloakAuthClient.hasAccess(accessToken, modelId)) {
return Response(Status.FORBIDDEN).body("Access denied to model $modelId")
}

val targetUri = anylogicUpstream.path("/$path").query(request.uri.query)

val forwardReq = when {
request.method == Method.POST && path.matches(animationStartPathRegex) -> {
val bodyWithToken = injectTokenAsInputParameter(request.bodyString(), idTokenProvider(request))
request.uri(targetUri).body(bodyWithToken)
request
.uri(targetUri)
.header("Authorization", "Bearer $anylogicApiKey")
.header("Content-Type", "application/json")
.body(bodyWithToken)
}

else -> request.uri(targetUri) // passthrough
else -> request.uri(targetUri)
.header("Authorization", "Bearer $anylogicApiKey")
}

return httpClient(forwardReq)
}


/**
* Resolves modelId from versionId by querying AnyLogic Cloud API
*/
private fun resolveModelId(versionId: String): String? {
return try {
anylogicCloudClient.models.firstNotNullOfOrNull { model ->
model.modelVersions.find { it == versionId }?.let { model.id }
}
} catch (e: Exception) {
println("Error resolving modelId for version $versionId: ${e.message}")
null
}
}

/**
* Injects the user's ID token into the AnyLogic request body if needed.
*
Expand Down
41 changes: 41 additions & 0 deletions backend/src/main/kotlin/keycloak/KeycloakClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.zenmo.backend.keycloak

import com.zenmo.backend.Config
import org.http4k.security.AccessToken
import org.keycloak.authorization.client.AuthzClient
import org.keycloak.authorization.client.Configuration
import org.keycloak.representations.idm.authorization.AuthorizationRequest

class KeycloakAuthClient(
serverUrl: String = "https://keycloak.zenmo.com",
realm: String = "zenmo",
clientId: String = Config().clientId, // maybe im wrong
clientSecret: String = Config().clientSecret
) {
private val authzClient: AuthzClient

init {
val config = Configuration(
serverUrl,
realm,
clientId,
mapOf("secret" to clientSecret),
null
)
authzClient = AuthzClient.create(config)
}


fun hasAccess(accessToken: AccessToken, modelResourceId: String): Boolean {
return try {
val request = AuthorizationRequest().apply {
addPermission(modelResourceId)
}
val response = authzClient.authorization(accessToken.value).authorize(request)
response.token != null
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}