Skip to content

Commit

Permalink
Merge pull request #1039 from modelix/fix/MODELIX-1016
Browse files Browse the repository at this point in the history
fix(model-server): make specifying a key ID optional
slisson authored Nov 6, 2024
2 parents c265a17 + 069ee0b commit 4119de1
Showing 7 changed files with 408 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ package org.modelix.authorization
import com.auth0.jwk.JwkProvider
import com.auth0.jwk.JwkProviderBuilder
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.interfaces.DecodedJWT
import io.ktor.server.application.Application
@@ -134,22 +135,23 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
hmac384KeyFromEnv?.let { return@lazy Algorithm.HMAC384(it) }
hmac256KeyFromEnv?.let { return@lazy Algorithm.HMAC256(it) }

val jwk = cachedJwkProvider?.get(jwkKeyId)
if (jwk != null) {
val publicKey = jwk.publicKey as? RSAPublicKey ?: error("Invalid key type: ${jwk.publicKey}")
return@lazy when (jwk.algorithm) {
"RS256" -> Algorithm.RSA256(publicKey, null)
"RSA384" -> Algorithm.RSA384(publicKey, null)
"RS512" -> Algorithm.RSA512(publicKey, null)
else -> error("Unsupported algorithm: ${jwk.algorithm}")
}
val localJwkProvider = cachedJwkProvider
val localJwkKeyId = jwkKeyId
if (localJwkProvider == null || localJwkKeyId == null) {
return@lazy null
}

null
return@lazy getAlgorithmFromJwkProviderAndKeyId(localJwkProvider, localJwkKeyId)
}

fun getJwtSignatureAlgorithm(): Algorithm {
return checkNotNull(algorithm) { "No signature algorithm configured" }
private fun getAlgorithmFromJwkProviderAndKeyId(jwkProvider: JwkProvider, jwkKeyId: String): Algorithm {
val jwk = jwkProvider.get(jwkKeyId)
val publicKey = jwk.publicKey as? RSAPublicKey ?: error("Invalid key type: ${jwk.publicKey}")
return when (jwk.algorithm) {
"RS256" -> Algorithm.RSA256(publicKey, null)
"RSA384" -> Algorithm.RSA384(publicKey, null)
"RS512" -> Algorithm.RSA512(publicKey, null)
else -> error("Unsupported algorithm: ${jwk.algorithm}")
}
}

fun getJwtSignatureAlgorithmOrNull(): Algorithm? {
@@ -161,10 +163,17 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
}

fun verifyTokenSignature(token: DecodedJWT) {
val algorithm = getJwtSignatureAlgorithm()
val verifier = JWT.require(algorithm)
.acceptLeeway(0L)
.build()
val algorithm = getJwtSignatureAlgorithmOrNull()
val jwkProvider = getJwkProvider()

val verifier = if (algorithm != null) {
getVerifierForSpecificAlgorithm(algorithm)
} else if (jwkProvider != null) {
val algorithmForKeyFromToken = getAlgorithmFromJwkProviderAndKeyId(jwkProvider, token.keyId)
getVerifierForSpecificAlgorithm(algorithmForKeyFromToken)
} else {
error("Either an JWT algorithm or a JWK URI must be configured.")
}
verifier.verify(token)
}

@@ -178,8 +187,19 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
}
}

fun shouldGenerateFakeTokens() = generateFakeTokens ?: (algorithm == null)
fun permissionCheckingEnabled() = permissionChecksEnabled ?: (algorithm != null)
// TODO MODELIX-1019 Instead of creating a fake token, we should refactor our code to work without a username
// when no authentication and authorization is configured.
/**
* Whether a fake token should be generated based on the configuration values provided.
*
* The fake token is generated so that we always have a username that can be used in the server logic.
*/
fun shouldGenerateFakeTokens() = generateFakeTokens ?: (algorithm == null && cachedJwkProvider == null)

/**
* Whether permission checking should be enabled based on the configuration values provided.
*/
fun permissionCheckingEnabled() = permissionChecksEnabled ?: (algorithm != null || cachedJwkProvider != null)

override fun configureForUnitTests() {
generateFakeTokens = true
@@ -198,3 +218,7 @@ private fun getBooleanFromEnv(name: String): Boolean? {
throw IllegalArgumentException("Failed to read boolean value $name", ex)
}
}

internal fun getVerifierForSpecificAlgorithm(algorithm: Algorithm): JWTVerifier =
JWT.require(algorithm)
.build()
Original file line number Diff line number Diff line change
@@ -18,6 +18,9 @@ package org.modelix.authorization

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException
import com.auth0.jwt.interfaces.DecodedJWT
import com.auth0.jwt.interfaces.JWTVerifier
import com.google.common.cache.CacheBuilder
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
@@ -80,29 +83,18 @@ object ModelixAuthorization : BaseRouteScopedPlugin<IModelixAuthorizationConfig,
} else {
// "Authorization: Bearer ..." header is provided in the header by OAuth proxy
jwt(MODELIX_JWT_AUTH) {
val jwkProvider = config.getJwkProvider()
if (jwkProvider != null) {
verifier(jwkProvider) {}
} else {
verifier(
JWT.require(config.getJwtSignatureAlgorithm())
.build(),
)
}
verifier(config.getVerifier())
challenge { _, _ ->
call.respond(status = HttpStatusCode.Unauthorized, "No or invalid JWT token provided")
// login and token generation is done by OAuth proxy. Only validation is required here.
}
validate {
try {
val token = jwtFromHeaders()
if (token != null) {
return@validate config.nullIfInvalid(token)?.let { AccessTokenPrincipal(it) }
}
jwtFromHeaders()?.let(::AccessTokenPrincipal)
} catch (e: Exception) {
LOG.warn(e) { "Failed to read JWT token" }
null
}
null
}
}
}
@@ -201,3 +193,21 @@ class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig)
}
}
}

/**
* Returns an [JWTVerifier] that wraps our common authorization logic,
* so that it can be configured in the verification with Ktor's JWT authorization.
*/
internal fun ModelixAuthorizationConfig.getVerifier() = object : JWTVerifier {
override fun verify(token: String?): DecodedJWT {
val jwt = JWT.decode(token)
return verify(jwt)
}

override fun verify(jwt: DecodedJWT?): DecodedJWT {
if (jwt == null) {
throw JWTVerificationException("No JWT provided.")
}
return this@getVerifier.nullIfInvalid(jwt) ?: throw JWTVerificationException("JWT invalid.")
}
}
Original file line number Diff line number Diff line change
@@ -64,11 +64,11 @@ To enable it you can specify the following environment variables.
|Variable |Description

|MODELIX_PERMISSION_CHECKS_ENABLED
|By default, permission checking is enabled when an algorithm for the JWT signature is configured.
|By default, permission checking is enabled when an algorithm for the JWT signature or a `MODELIX_JWK_URI` is configured.
This variable can be set explicitly to `true` or `false` to avoid security issues by a misconfigured algorithm.

|MODELIX_GENERATE_FAKE_JWT
|By default, if no signature algorithm is configured,
|By default, if no signature algorithm and no `MODELIX_JWK_URI` is configured,
a token is generated for all requests with the identity `unit-tests@example.com` and no permissions.
This option can be set to `true` or `false` to enable/disable this behaviour explicitly.

@@ -90,7 +90,10 @@ To enable it you can specify the following environment variables.
|MODELIX_JWK_URI
|If keys are created and signed by some OpenID connect server the public keys are provided via HTTP.
Here you can specify the URI of the key set.
Only RSA (256, 284 and 512) keys are currently supported.
Only RSA (256, 384 and 512) keys are currently supported.

|MODELIX_JWK_KEY_ID
|Optional key ID that can be used together with `MODELIX_JWK_URI`. If specified, it ensures that only tokens that use the specified key are valid. If not specified, a token can use any RSA (256, 384 and 512) key provided by `MODELIX_JWK_URI`.

|KEYCLOAK_BASE_URL
KEYCLOAK_REALM
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@ detekt = "1.23.7"
xmlunit = "2.10.0"
kotest = "5.9.1"
testcontainers = "1.20.3"
keycloak = "26.0.2"

[libraries]

@@ -85,7 +86,8 @@ ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref =
ktor-serialization = { group = "io.ktor", name = "ktor-serialization", version.ref = "ktor" }
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }

keycloak-authz-client = { group = "org.keycloak", name = "keycloak-authz-client", version = "26.0.2" }
keycloak-authz-client = { group = "org.keycloak", name = "keycloak-authz-client", version.ref = "keycloak" }
keycloak-admin-client = { group = "org.keycloak", name = "keycloak-admin-client", version.ref = "keycloak" }

kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
kotest-assertions-coreJvm = { group = "io.kotest", name = "kotest-assertions-core-jvm", version.ref = "kotest" }
2 changes: 2 additions & 0 deletions model-server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -85,6 +85,8 @@ dependencies {
testImplementation(project(":modelql-untyped"))
testImplementation(libs.testcontainers)
testImplementation(libs.testcontainers.postgresql)
testImplementation(libs.keycloak.authz.client)
testImplementation(libs.keycloak.admin.client)
}

tasks.named<ShadowJar>("shadowJar") {
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
/*
* 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.server

import com.auth0.jwt.JWT
import io.ktor.client.plugins.ClientRequestException
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import org.apache.http.impl.client.HttpClients
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import org.keycloak.OAuth2Constants
import org.keycloak.admin.client.Keycloak
import org.keycloak.admin.client.KeycloakBuilder
import org.keycloak.authorization.client.AuthzClient
import org.keycloak.authorization.client.Configuration
import org.modelix.authorization.IModelixAuthorizationConfig
import org.modelix.authorization.ModelixAuthorization
import org.modelix.model.client2.ModelClientV2
import org.modelix.model.server.handlers.IdsApiImpl
import org.modelix.model.server.handlers.ModelReplicationServer
import org.modelix.model.server.handlers.RepositoriesManager
import org.modelix.model.server.store.InMemoryStoreClient
import org.modelix.model.server.store.LocalModelClient
import org.testcontainers.containers.GenericContainer
import org.testcontainers.images.builder.Transferable
import java.net.URI
import kotlin.test.Test
import kotlin.test.assertEquals

private const val ADMIN_USER = "admin"
private const val ADMIN_PASSWORD = "admin"
private const val REALM = "authorization-test-realm"
private const val CLIENT_ID = "authorization-test-client"
private const val USER = "authorization-test-user"
private const val USER_PASSWORD = "authorization-test-user-password"

class AuthorizationTest {

companion object {
// Configure `clients[0].publicClient` because we want to log in without specifying a client secret.
// Configure `clients[0].directAccessGrantsEnabled` because we want to directly log in with a password and username.
// Configure `users[0].email` because it is required by default.
// Configure `clientScopes` so that we can put Modelix `permissions` into the token.
// See https://stackoverflow.com/questions/78528623/keycloak-move-from-23-0-to-24-0-account-is-not-fully-set-up-invalid-grant
// It could be configured to be optional but doing so is more complicated than just adding it for the test user.
// Configure `users[0].firstName` for the same reason as for `users[0].email`
// Configure `users[0].lastName` for the same reason as for `users[0].email`.
// Configure `components."org.keycloak.keys.KeyProvider"` so that we can test using a token with a wrong key.
// language=json
private const val REALM_CONFIGURATION = """
{
"realm": "$REALM",
"enabled": true,
"clients": [
{
"clientId": "$CLIENT_ID",
"enabled": true,
"directAccessGrantsEnabled": true,
"publicClient": true,
"defaultClientScopes": ["authorization-test-permissions-claim"]
}
],
"clientScopes": [
{
"name": "authorization-test-permissions-claim",
"description": "",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "true",
"gui.order": "",
"consent.screen.text": ""
},
"protocolMappers": [
{
"name": "authorization-test-permissions-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"claim.value": "[\"model-server/admin\"]",
"userinfo.token.claim": "true",
"id.token.claim": "true",
"lightweight.claim": "false",
"access.token.claim": "true",
"claim.name": "permissions",
"jsonType.label": "JSON",
"access.tokenResponse.claim": "false"
}
}
]
}
],
"users": [
{
"username": "$USER",
"email": "authorization-test-user@authorization-test-user.com",
"firstName": "authorization-test-user",
"lastName": "authorization-test-user",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "$USER_PASSWORD"
}
]
}
],
"components": {
"org.keycloak.keys.KeyProvider": [
{
"name": "rsa-256-generated",
"providerId": "rsa-generated",
"subComponents": {},
"config": {
"keySize": [
"2048"
],
"active": [
"true"
],
"priority": [
"100"
],
"enabled": [
"true"
],
"algorithm": [
"RS256"
]
}
},
{
"name": "rsa-512-generated",
"providerId": "rsa-generated",
"subComponents": {},
"config": {
"keySize": [
"2048"
],
"active": [
"true"
],
"priority": [
"0"
],
"enabled": [
"true"
],
"algorithm": [
"RS512"
]
}
}
]
}
}
"""

// Reuse on container across all tests. The configuration and state does not change in between.
private val keycloak: GenericContainer<*> = GenericContainer("quay.io/keycloak/keycloak:25.0.4")
.withEnv("KEYCLOAK_ADMIN", ADMIN_USER)
.withEnv("KEYCLOAK_ADMIN_PASSWORD", ADMIN_PASSWORD)
.withExposedPorts(8080)
.withCopyToContainer(Transferable.of(REALM_CONFIGURATION), "/opt/keycloak/data/import/realm.json")
.withCommand("start-dev", "--import-realm", "--verbose")

private var keycloakBaseUrl: String
private var keycloakAdminClient: Keycloak
private var keycloakAuthorizationClient: AuthzClient

init {
keycloak.start()
keycloakBaseUrl = "http://${keycloak.host}:${keycloak.firstMappedPort}"

keycloakAdminClient = KeycloakBuilder.builder()
.serverUrl(keycloakBaseUrl)
.realm("master")
.clientId("admin-cli")
.grantType(OAuth2Constants.PASSWORD)
.username(ADMIN_USER)
.password(ADMIN_PASSWORD)
.build()

val configuration = Configuration(
keycloakBaseUrl,
REALM,
CLIENT_ID,
mapOf("secret" to ""),
HttpClients.createDefault(),
)
keycloakAuthorizationClient = AuthzClient.create(configuration)
}
}

private fun obtainTokenForTestUser(): String {
val token = keycloakAuthorizationClient.obtainAccessToken(USER, USER_PASSWORD).token
return token
}

private fun runAuthorizationTest(
modelixAuthorizationConfig: IModelixAuthorizationConfig.() -> Unit,
block: suspend ApplicationTestBuilder.() -> Unit,
) = testApplication {
application {
install(ModelixAuthorization, modelixAuthorizationConfig)
installDefaultServerPlugins()
val storeClient = InMemoryStoreClient()
val modelClient = LocalModelClient(storeClient)
val repositoriesManager = RepositoriesManager(modelClient)
ModelReplicationServer(repositoriesManager).init(this)
IdsApiImpl(repositoriesManager).init(this)
}
block()
}

@Test
fun `authorization uses key ID from JWT if no key ID is specified`() {
val modelixAuthorizationConfig: IModelixAuthorizationConfig.() -> Unit = {
permissionSchema = ModelServerPermissionSchema.SCHEMA
jwkUri = URI("$keycloakBaseUrl/realms/$REALM/protocol/openid-connect/certs")
}
val token = obtainTokenForTestUser()
runAuthorizationTest(modelixAuthorizationConfig) {
val modelClient = createModelClient(token)

assertDoesNotThrow {
modelClient.init()
}
}
}

@Test
fun `authorization fails to use key ID if it does not exist at JWK URI`() {
val modelixAuthorizationConfig: IModelixAuthorizationConfig.() -> Unit = {
permissionSchema = ModelServerPermissionSchema.SCHEMA
jwkUri = URI("$keycloakBaseUrl/realms/master/protocol/openid-connect/certs")
}
val token = obtainTokenForTestUser()
runAuthorizationTest(modelixAuthorizationConfig) {
val modelClient = createModelClient(token)

val exception = assertThrows<ClientRequestException> { modelClient.init() }

assertEquals(HttpStatusCode.Unauthorized, exception.response.status)
}
}

@Test
fun `authorization succeeds if key ID in token matches the configured key ID from JWK URI`() {
val token = obtainTokenForTestUser()
val decodedToken = JWT.decode(token)
val keyIdInToken = decodedToken.keyId
val modelixAuthorizationConfig: IModelixAuthorizationConfig.() -> Unit = {
permissionSchema = ModelServerPermissionSchema.SCHEMA
jwkUri = URI("$keycloakBaseUrl/realms/$REALM/protocol/openid-connect/certs")
jwkKeyId = keyIdInToken
}
runAuthorizationTest(modelixAuthorizationConfig) {
val modelClient = createModelClient(token)

assertDoesNotThrow {
modelClient.init()
}
}
}

@Test
fun `authorization fails if key ID in token does not match the configured key ID from JWK URI`() {
val token = obtainTokenForTestUser()
val decodedToken = JWT.decode(token)
val keyIdInToken = decodedToken.keyId
val keyIdNotUsedInToken = keycloakAdminClient.realm(REALM).keys().keyMetadata.keys
.map { it.kid }
.first { it != keyIdInToken }
val modelixAuthorizationConfig: IModelixAuthorizationConfig.() -> Unit = {
permissionSchema = ModelServerPermissionSchema.SCHEMA
jwkUri = URI("$keycloakBaseUrl/realms/$REALM/protocol/openid-connect/certs")
jwkKeyId = keyIdNotUsedInToken
}
runAuthorizationTest(modelixAuthorizationConfig) {
val modelClient = createModelClient(token)

val exception = assertThrows<ClientRequestException> { modelClient.init() }

assertEquals(HttpStatusCode.Unauthorized, exception.response.status)
}
}

private fun ApplicationTestBuilder.createModelClient(token: String): ModelClientV2 {
val modelClient = ModelClientV2.builder()
.url("http://localhost/v2")
.client(client)
.authToken { token }
.build()
return modelClient
}
}
15 changes: 15 additions & 0 deletions model-server/src/test/resources/logback-test.xml
Original file line number Diff line number Diff line change
@@ -9,4 +9,19 @@
<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"/>

<!--
Reduce log output by Keycloak and the HTTP client used by it.
-->
<logger name="org.keycloak" level="INFO"/>
<logger name="org.apache.http" level="INFO"/>
</configuration>

0 comments on commit 4119de1

Please sign in to comment.