Skip to content

Commit

Permalink
feat(authorization): support for identity tokens
Browse files Browse the repository at this point in the history
Identity tokens don't contain any permissions. The permissions are then loaded based on the user ID
and roles.
  • Loading branch information
slisson committed Dec 11, 2024
1 parent e4b5415 commit 31c2c16
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import com.auth0.jwt.interfaces.DecodedJWT
import io.ktor.server.auth.Principal

class AccessTokenPrincipal(val jwt: DecodedJWT) : Principal {
fun getUserName(): String? = jwt.getClaim("email")?.asString()
?: jwt.getClaim("preferred_username")?.asString()
fun getUserName(): String? = ModelixJWTUtil().extractUserId(jwt)

override fun equals(other: Any?): Boolean {
if (other !is AccessTokenPrincipal) return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ interface IModelixAuthorizationConfig {
*/
var permissionSchema: Schema

var accessControlDataProvider: IAccessControlDataProvider

/**
* Generates fake tokens and allows all requests.
*/
Expand All @@ -105,6 +107,7 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
}
override var jwkKeyId: String? = System.getenv("MODELIX_JWK_KEY_ID")
override var permissionSchema: Schema = buildPermissionSchema { }
override var accessControlDataProvider: IAccessControlDataProvider = EmptyAccessControlDataProvider()

private val hmac512KeyFromEnv by lazy {
System.getenv("MODELIX_JWT_SIGNATURE_HMAC512_KEY")
Expand All @@ -119,9 +122,10 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
?: System.getenv("MODELIX_JWT_SIGNATURE_HMAC256_KEY_FILE")?.let { File(it).readText() }
}

private val jwtUtil: ModelixJWTUtil by lazy {
val jwtUtil: ModelixJWTUtil by lazy {
val util = ModelixJWTUtil()

util.accessControlDataProvider = accessControlDataProvider
util.loadKeysFromEnvironment()

listOfNotNull<Pair<String, JWSAlgorithm>>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ object ModelixAuthorization : BaseRouteScopedPlugin<IModelixAuthorizationConfig,

application.routing {
get(".well-known/jwks.json") {
call.respondText(JWKSet(listOfNotNull(config.ownPublicKey)).toPublicJWKSet().toString(), ContentType.Application.Json)
call.respondText(
JWKSet(listOfNotNull(config.ownPublicKey)).toPublicJWKSet().toString(),
ContentType.Application.Json,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.modelix.authorization

import org.modelix.authorization.permissions.PermissionParts

interface IAccessControlDataProvider {
fun getGrantedPermissionsForUser(userId: String): Set<PermissionParts>
fun getGrantedPermissionsForRole(role: String): Set<PermissionParts>
}

class EmptyAccessControlDataProvider : IAccessControlDataProvider {
override fun getGrantedPermissionsForUser(userId: String): Set<PermissionParts> {
return emptySet()
}

override fun getGrantedPermissionsForRole(role: String): Set<PermissionParts> {
return emptySet()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.modelix.authorization

import com.auth0.jwt.interfaces.DecodedJWT
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JWSObject
Expand Down Expand Up @@ -31,6 +32,9 @@ import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking
import org.modelix.authorization.permissions.PermissionEvaluator
import org.modelix.authorization.permissions.Schema
import org.modelix.authorization.permissions.SchemaInstance
import java.io.File
import java.net.URI
import java.net.URL
Expand All @@ -51,6 +55,7 @@ class ModelixJWTUtil {
private val jwksUrls = LinkedHashSet<URL>()
private var expectedKeyId: String? = null
private var ktorClient: HttpClient? = null
var accessControlDataProvider: IAccessControlDataProvider = EmptyAccessControlDataProvider()

fun canVerifyTokens(): Boolean {
return hmacKeys.isNotEmpty() || rsaPublicKeys.isNotEmpty() || jwksUrls.isNotEmpty()
Expand Down Expand Up @@ -143,6 +148,51 @@ class ModelixJWTUtil {
return JWSObject(header, payload).also { it.sign(signer) }.serialize()
}

fun createPermissionEvaluator(token: DecodedJWT, schema: Schema): PermissionEvaluator {
return createPermissionEvaluator(token, SchemaInstance(schema))
}

fun createPermissionEvaluator(token: DecodedJWT, schema: SchemaInstance): PermissionEvaluator {
return PermissionEvaluator(schema).also { loadGrantedPermissions(token, it) }
}

fun loadGrantedPermissions(token: DecodedJWT, evaluator: PermissionEvaluator) {
val permissions = token.claims["permissions"]?.asList(String::class.java)

// There is a difference between access tokens and identity tokens.
// An identity token just contains the user ID and the service has to know the granted permissions.
// An access token has more limited permissions and is issued for a specific task. It contains the list of
// granted permissions. Since tokens are signed and created by a trusted authority we don't have to check the
// list of permissions against our own access control data.
if (permissions != null) {
permissions.forEach { evaluator.grantPermission(it) }
} else {
val directGrants = extractUserId(token)?.let { userId ->
accessControlDataProvider.getGrantedPermissionsForUser(userId)
}.orEmpty() + extractUserRoles(token).flatMap { role ->
accessControlDataProvider.getGrantedPermissionsForRole(role)
}.toSet()
directGrants.forEach { permission ->
evaluator.grantPermission(permission)
}
}
}

fun extractUserId(jwt: DecodedJWT): String? {
return jwt.getClaim("email")?.asString()
?: jwt.getClaim("preferred_username")?.asString()
}

fun extractUserRoles(jwt: DecodedJWT): List<String> {
val keycloakRoles = jwt
.getClaim("realm_access")?.asMap()
?.get("roles")
?.let { it as? List<*> }
?.mapNotNull { it as? String }
?: emptyList()
return keycloakRoles
}

fun generateRSAPrivateKey(): JWK {
return RSAKeyGenerator(2048)
.keyUse(KeyUse.SIGNATURE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.modelix.authorization

import com.auth0.jwt.JWT
import kotlin.test.Test
import kotlin.test.assertEquals

class RolesFromTokenTest {

@Test
fun `extract roles from token created by Keycloak`() {
val token = JWT.decode("eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5T1VVR2pRa3ZQRE1OMmJjcFA1RDRLUnpOM3l2elRWOV9TZFVrdlpUUG1NIn0.eyJleHAiOjE3MzIwNTgzODcsImlhdCI6MTczMjAyMjM4NywiYXV0aF90aW1lIjoxNzMyMDA1Njk5LCJqdGkiOiI3MjI4ZTkxMS03YmY3LTQ3YWMtODYxMy1jNDQyNTYwODJjZDkiLCJpc3MiOiJodHRwczovL2xvY2FsaG9zdC9yZWFsbXMvbW9kZWxpeCIsImF1ZCI6WyJtb2RlbGl4IiwiYWNjb3VudCJdLCJzdWIiOiIzZmYwYWMxNi00NjU4LTRjOTItOGUyZS01NTIwNTM1YzFhN2YiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJtb2RlbGl4Iiwibm9uY2UiOiI3ZmtBMFJ6ZUhSNkpNa0ZZeV9qTEQ1cE5aSmdRdVVBNUszcVFyUjdTeVR3Iiwic2Vzc2lvbl9zdGF0ZSI6IjU2NDIzYTZiLTM2OGUtNDZjYS04ZDM0LWI5YTA1ZjExM2Q0NyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibW9kZWxpeC11c2VyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtbW9kZWxpeCJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im1vZGVsaXgiOnsicm9sZXMiOlsidW1hX3Byb3RlY3Rpb24iXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiI1NjQyM2E2Yi0zNjhlLTQ2Y2EtOGQzNC1iOWEwNWYxMTNkNDciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJTIEwiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbEBxNjAuZGUiLCJnaXZlbl9uYW1lIjoiUyIsImZhbWlseV9uYW1lIjoiTCIsImVtYWlsIjoic2xAcTYwLmRlIn0.m-PW8gNmrjQhLJw6BJez-pQSUk8jMZ5QB2HuPv-pyJZon6idsxp5sSpMelWb_3Cb78BEf5AeSbzxB_yZJEf7uFbAYURsRAumaiq8u5HofHuwIoofyCoJjGKlBYnkZpL1mNRPy1sHZfdMre3Yh6bKsztz0PWaEVlSx8wGyXPup84p2uy5-k0eThAI2zKmIa-YxGXmCwb0IbQakp5Q77mQeWa1e_ozr4zf72ScbvB80ourRJEY6YwkZyEbIoM015CvlE3hgN5fL0AVg9Zr18pY4oSwwNYbIiaIbWlUN29QcelDq1jX969fIQw2O1GJEusU3K_ZtWZJsMZdPWYpxf-uiw")
val roles = ModelixJWTUtil().extractUserRoles(token)
assertEquals(listOf("modelix-user", "offline_access", "uma_authorization", "default-roles-modelix"), roles)
}
}

0 comments on commit 31c2c16

Please sign in to comment.