Skip to content

Commit

Permalink
Enable admins to query without restrictions (#142)
Browse files Browse the repository at this point in the history
- Reads the `Authorization Bearer` token from the request (if present)
  and decodes it to a pre-known structure `RenkuToken`
- a caller is an admin, if the role `renku-admin` is in
  `realmAccess.roles`
- add another `AuthContext` for admins and convert to existing
  `SearchRole.Admin` which will remove any constraints to a given user
  query
- Refactors auth-code in search routes for better testability
  • Loading branch information
eikek authored May 29, 2024
1 parent 0133126 commit c1e122c
Show file tree
Hide file tree
Showing 25 changed files with 666 additions and 144 deletions.
16 changes: 9 additions & 7 deletions modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,27 @@ import io.bullet.borer.Json
import pdi.jwt.*

class JwtBorer(override val clock: Clock)
extends JwtCore[JwtHeader, JwtClaim]
extends JwtCore[JwtHeader, RenkuToken]
with BorerCodec:
private val noSigOptions = JwtOptions.DEFAULT.copy(signature = false)

protected def parseHeader(header: String): JwtHeader =
Json.decode(header.getBytes).to[JwtHeader].value

protected def parseClaim(claim: String): JwtClaim =
Json.decode(claim.getBytes).to[JwtClaim].value
protected def parseClaim(claim: String): RenkuToken =
Json.decode(claim.getBytes).to[RenkuToken].value

protected def extractAlgorithm(header: JwtHeader): Option[JwtAlgorithm] =
header.algorithm
protected def extractExpiration(claim: JwtClaim): Option[Long] = claim.expiration
protected def extractNotBefore(claim: JwtClaim): Option[Long] = claim.notBefore
protected def extractExpiration(claim: RenkuToken): Option[Long] =
claim.expirationTime.map(_.getEpochSecond)
protected def extractNotBefore(claim: RenkuToken): Option[Long] =
claim.notBefore.map(_.getEpochSecond())

def decodeAllNoSignatureCheck(token: String): Try[(JwtHeader, JwtClaim, String)] =
def decodeAllNoSignatureCheck(token: String): Try[(JwtHeader, RenkuToken, String)] =
decodeAll(token, noSigOptions)

def decodeNoSignatureCheck(token: String): Try[JwtClaim] =
def decodeNoSignatureCheck(token: String): Try[RenkuToken] =
decode(token, noSigOptions)

object JwtBorer extends JwtBorer(Clock.systemUTC()):
Expand Down
64 changes: 64 additions & 0 deletions modules/jwt/src/main/scala/io/renku/search/jwt/RenkuToken.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* 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 io.renku.search.jwt

import java.time.Instant

import io.bullet.borer.NullOptions.given
import io.bullet.borer.derivation.MapBasedCodecs
import io.bullet.borer.derivation.key
import io.bullet.borer.{Decoder, Encoder}
import io.renku.search.jwt.RenkuToken.{Access, AccountRoles}

final case class RenkuToken(
@key("exp") expirationTime: Option[Instant] = None,
@key("iat") issuedAt: Option[Instant] = None,
@key("nbf") notBefore: Option[Instant] = None,
@key("auth_time") authTime: Option[Instant] = None,
@key("jti") jwtId: Option[String] = None,
@key("iss") issuer: Option[String] = None,
@key("sub") subject: Option[String] = None,
@key("typ") tokenType: Option[String] = None,
@key("realm_access") realmAccess: Option[Access] = None,
@key("resource_access") resourceAccess: Option[AccountRoles] = None,
@key("scope") scopeStr: Option[String] = None,
name: Option[String] = None,
email: Option[String] = None,
@key("email_verified") emailVerified: Boolean = false,
groups: Set[String] = Set.empty,
@key("preferred_username") preferredUsername: Option[String] = None
):

lazy val isAdmin =
realmAccess.exists(_.roles.contains("renku-admin"))

object RenkuToken:
final case class Access(roles: Set[String] = Set.empty)
final case class AccountRoles(account: Access = Access())

private given Decoder[Instant] = Decoder.forLong.map(Instant.ofEpochSecond(_))
private given Encoder[Instant] = Encoder.forLong.contramap(_.getEpochSecond())

private given Decoder[Access] = MapBasedCodecs.deriveDecoder
private given Encoder[Access] = MapBasedCodecs.deriveEncoder
private given Decoder[AccountRoles] = MapBasedCodecs.deriveDecoder
private given Encoder[AccountRoles] = MapBasedCodecs.deriveEncoder

given Decoder[RenkuToken] = MapBasedCodecs.deriveDecoder
given Encoder[RenkuToken] = MapBasedCodecs.deriveEncoder
49 changes: 49 additions & 0 deletions modules/jwt/src/test/resources/jwt1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"exp": 1716905204,
"iat": 1716903404,
"auth_time": 1716903404,
"jti": "488ecd30-a7bb-473b-9eac-3ec43d5447a6",
"iss": "https://ci-renku-3646.dev.renku.ch/auth/realms/Renku",
"aud": [
"renku",
"account"
],
"sub": "48c85c75-b407-4259-b06b-a611e71df5f0",
"typ": "Bearer",
"azp": "renku-ui",
"session_state": "77bd8cc2-8230-4ec8-82d3-617bc3bf8013",
"acr": "1",
"allowed-origins": [
"https://ci-renku-3646.dev.renku.ch/*"
],
"realm_access": {
"roles": [
"offline_access",
"default-roles-renku",
"uma_authorization"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid microprofile-jwt email profile",
"sid": "77bd8cc2-8230-4ec8-82d3-617bc3bf8013",
"upn": "[email protected]",
"email_verified": false,
"name": "Eike Kettner",
"groups": [
"offline_access",
"default-roles-renku",
"uma_authorization"
],
"preferred_username": "[email protected]",
"given_name": "Eike",
"family_name": "Kettner",
"email": "[email protected]"
}
59 changes: 59 additions & 0 deletions modules/jwt/src/test/resources/jwt2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"exp": 1716906437,
"iat": 1716904637,
"auth_time": 1716904637,
"jti": "349d20d3-5ac2-4df6-b5e9-44bc8426b0ed",
"iss": "https://ci-renku-3646.dev.renku.ch/auth/realms/Renku",
"aud": [
"renku",
"realm-management",
"account"
],
"sub": "48c85c75-b407-4259-b06b-a611e71df5f0",
"typ": "Bearer",
"azp": "renku-ui",
"session_state": "6f365bb5-2b3b-403c-ab97-e026292f5269",
"acr": "1",
"allowed-origins": [
"https://ci-renku-3646.dev.renku.ch/*"
],
"realm_access": {
"roles": [
"offline_access",
"renku-admin",
"default-roles-renku",
"uma_authorization"
]
},
"resource_access": {
"realm-management": {
"roles": [
"view-users",
"query-groups",
"query-users"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid microprofile-jwt email profile",
"sid": "6f365bb5-2b3b-403c-ab97-e026292f5269",
"upn": "[email protected]",
"email_verified": false,
"name": "Eike Kettner",
"groups": [
"offline_access",
"renku-admin",
"default-roles-renku",
"uma_authorization"
],
"preferred_username": "[email protected]",
"given_name": "Eike",
"family_name": "Kettner",
"email": "[email protected]"
}
16 changes: 12 additions & 4 deletions modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

package io.renku.search.jwt

import java.time.Instant

import munit.FunSuite
import pdi.jwt.Jwt
import pdi.jwt.JwtAlgorithm
Expand All @@ -30,21 +32,27 @@ class JwtBorerSpec extends FunSuite:

val regexDecode = Jwt.decodeAll(exampleToken, secret).get

val expectClaim = RenkuToken(
issuedAt = Some(Instant.ofEpochSecond(1516239022L)),
subject = Some("my-user-id"),
name = Some("John Doe")
)

test("decode"):
val (header, claim, _) = JwtBorer.decodeAll(exampleToken, secret).get
assertEquals(header.algorithm, Some(JwtAlgorithm.HS256))
assertEquals(header.typ, Some("JWT"))
assertEquals(header.keyId, None)
assertEquals(header.contentType, None)

assertEquals(claim.subject, Some("my-user-id"))
assertEquals(claim.issuedAt, Some(1516239022L))
assertEquals(claim.subject, expectClaim.subject)
assertEquals(claim.issuedAt, expectClaim.issuedAt)
assertEquals(header, regexDecode._1)
assertEquals(claim, regexDecode._2.withContent("{}"))
assertEquals(claim, expectClaim)

test("decode without secret"):
val (header, claim, _) = JwtBorer.decodeAllNoSignatureCheck(exampleToken).get
val claim2 = JwtBorer.decodeNoSignatureCheck(exampleToken).get
assertEquals(claim, claim2)
assertEquals(header, regexDecode._1)
assertEquals(claim, regexDecode._2.withContent("{}"))
assertEquals(claim, expectClaim)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* 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 io.renku.search.jwt

import scala.io.Source

import io.bullet.borer.Json
import munit.FunSuite

class RenkuTokenSpec extends FunSuite:

test("decode jwt payload"):
val jsonStr = Source.fromResource("jwt1.json").mkString
val decoded = Json.decode(jsonStr.getBytes).to[RenkuToken].value
assertEquals(decoded.subject, Some("48c85c75-b407-4259-b06b-a611e71df5f0"))
assert(decoded.isAdmin == false)

test("decode jwt payload (admin)"):
val jsonStr = Source.fromResource("jwt2.json").mkString
val decoded = Json.decode(jsonStr.getBytes).to[RenkuToken].value
assertEquals(decoded.subject, Some("48c85c75-b407-4259-b06b-a611e71df5f0"))
assert(decoded.isAdmin == true)
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,11 @@ import cats.syntax.all.*

import io.renku.openid.keycloak.DefaultJwtVerify.State
import io.renku.search.http.borer.BorerEntityJsonCodec
import io.renku.search.jwt.JwtBorer
import io.renku.search.jwt.{JwtBorer, RenkuToken}
import org.http4s.Method.GET
import org.http4s.Uri
import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl
import pdi.jwt.JwtClaim

final class DefaultJwtVerify[F[_]: Async](
client: Client[F],
Expand All @@ -45,18 +44,18 @@ final class DefaultJwtVerify[F[_]: Async](

private val logger = scribe.cats.effect[F]

def tryDecode(issuer: Uri, token: String): EitherT[F, JwtError, JwtClaim] =
def tryDecode(issuer: Uri, token: String): EitherT[F, JwtError, RenkuToken] =
EitherT(state.get.flatMap(_.validate(issuer, clock, token)))

def tryDecodeOnly(token: String): F[Either[JwtError, JwtClaim]] =
def tryDecodeOnly(token: String): F[Either[JwtError, RenkuToken]] =
JwtBorer.create[F](using clock).map { jwtb =>
jwtb
.decodeNoSignatureCheck(token)
.toEither
.leftMap(ex => JwtError.JwtValidationError(token, None, None, ex))
}

def verify(token: String): F[Either[JwtError, JwtClaim]] =
def verify(token: String): F[Either[JwtError, RenkuToken]] =
tryDecodeOnly(token).flatMap {
case Left(err) => Left(err).pure[F]
case Right(c) if !config.enableSignatureValidation => Right(c).pure[F]
Expand All @@ -74,7 +73,7 @@ final class DefaultJwtVerify[F[_]: Async](

def updateCache(issuer: Uri, token: String)(
jwtError: JwtError
): F[Either[JwtError, JwtClaim]] =
): F[Either[JwtError, RenkuToken]] =
jwtError match
case JwtError.JwtValidationError(_, _, Some(claim), _) =>
(for
Expand All @@ -88,15 +87,15 @@ final class DefaultJwtVerify[F[_]: Async](
yield result).value
case e => Left(e).pure[F]

def readIssuer(claim: JwtClaim): Either[JwtError, Uri] =
def readIssuer(claim: RenkuToken): Either[JwtError, Uri] =
for
issuerUri <- Uri
.fromString(claim.issuer.getOrElse(""))
.leftMap(ex => JwtError.InvalidIssuerUrl(claim.issuer.getOrElse(""), ex))
_ <- config.checkIssuerUrl(issuerUri)
yield issuerUri

def fetchJWKSGuarded(issuer: Uri, claim: JwtClaim): EitherT[F, JwtError, Jwks] =
def fetchJWKSGuarded(issuer: Uri, claim: RenkuToken): EitherT[F, JwtError, Jwks] =
for
_ <- checkLastUpdateDelay(issuer, config.minRequestDelay)
result <- fetchJWKS(issuer, claim)
Expand All @@ -110,7 +109,7 @@ final class DefaultJwtVerify[F[_]: Async](
}
)

def fetchJWKS(issuerUri: Uri, claim: JwtClaim): EitherT[F, JwtError, Jwks] =
def fetchJWKS(issuerUri: Uri, claim: RenkuToken): EitherT[F, JwtError, Jwks] =
for
_ <- EitherT.right(
clock.monotonic.flatMap(t => state.update(_.setLastUpdate(issuerUri, t)))
Expand Down Expand Up @@ -147,7 +146,7 @@ object DefaultJwtVerify:
issuer: Uri,
clock: Clock[F],
token: String
): F[Either[JwtError, JwtClaim]] =
): F[Either[JwtError, RenkuToken]] =
get(issuer).jwks.validate(clock)(token)

def setLastUpdate(issuer: Uri, time: FiniteDuration): State =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ import cats.syntax.all.*

import io.bullet.borer.derivation.MapBasedCodecs
import io.bullet.borer.{Decoder, Encoder}
import io.renku.search.jwt.JwtBorer
import pdi.jwt.JwtClaim
import io.renku.search.jwt.{JwtBorer, RenkuToken}
import pdi.jwt.JwtHeader

final case class Jwks(
Expand All @@ -46,7 +45,9 @@ final case class Jwks(
pk <- wk.toPublicKey
yield pk

def validate[F[_]: Monad](clock: Clock[F])(jwt: String): F[Either[JwtError, JwtClaim]] =
def validate[F[_]: Monad](
clock: Clock[F]
)(jwt: String): F[Either[JwtError, RenkuToken]] =
JwtBorer.create[F](using clock).map { jwtCheck =>
for
(header, claim, _) <- jwtCheck
Expand Down
Loading

0 comments on commit c1e122c

Please sign in to comment.