diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutes.scala index 7ce6b38b0d..78bd7d3aad 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutes.scala @@ -2,23 +2,21 @@ package ch.epfl.bluebrain.nexus.delta.routes import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route +import cats.effect.IO import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives -import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ +import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller._ import kamon.instrumentation.akka.http.TracingDirectives.operationName -import monix.bio.IO -import monix.execution.Scheduler /** * The identities routes */ class IdentitiesRoutes(identities: Identities, aclCheck: AclCheck)(implicit - override val s: Scheduler, baseUri: BaseUri, cr: RemoteContextResolution, ordering: JsonKeyOrdering @@ -48,6 +46,6 @@ object IdentitiesRoutes { def apply( identities: Identities, aclCheck: AclCheck - )(implicit baseUri: BaseUri, s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering): Route = + )(implicit baseUri: BaseUri, cr: RemoteContextResolution, ordering: JsonKeyOrdering): Route = new IdentitiesRoutes(identities, aclCheck).routes } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala index a55d2d6028..d47caccc0f 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala @@ -29,14 +29,12 @@ import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.{Realm, RealmRejection} import io.circe.Decoder import io.circe.generic.extras.Configuration import io.circe.generic.extras.semiauto.deriveConfiguredDecoder -import monix.execution.Scheduler import scala.annotation.nowarn class RealmsRoutes(identities: Identities, realms: Realms, aclCheck: AclCheck)(implicit baseUri: BaseUri, paginationConfig: PaginationConfig, - s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering ) extends AuthDirectives(identities, aclCheck) @@ -142,7 +140,6 @@ object RealmsRoutes { def apply(identities: Identities, realms: Realms, aclCheck: AclCheck)(implicit baseUri: BaseUri, paginationConfig: PaginationConfig, - s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering ): Route = diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/SchemasRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/SchemasRoutes.scala index e2f9e6015e..f93d41a7ee 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/SchemasRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/SchemasRoutes.scala @@ -28,7 +28,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.schemas.Schemas import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.SchemaRejection.SchemaNotFound import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.{Schema, SchemaRejection} import io.circe.{Json, Printer} -import monix.execution.Scheduler /** * The schemas routes @@ -52,7 +51,6 @@ final class SchemasRoutes( indexAction: IndexingAction.Execute[Schema] )(implicit baseUri: BaseUri, - s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering, fusionConfig: FusionConfig @@ -192,7 +190,6 @@ object SchemasRoutes { index: IndexingAction.Execute[Schema] )(implicit baseUri: BaseUri, - s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering, fusionConfig: FusionConfig diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala index 8c32951dbf..a7674443ec 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala @@ -1,12 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.wiring -import akka.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} -import akka.http.scaladsl.model.{HttpRequest, Uri} import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority import ch.epfl.bluebrain.nexus.delta.config.AppConfig import ch.epfl.bluebrain.nexus.delta.kernel.cache.CacheConfig -import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ -import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering @@ -14,16 +10,12 @@ import ch.epfl.bluebrain.nexus.delta.routes.IdentitiesRoutes import ch.epfl.bluebrain.nexus.delta.sdk.PriorityRoute import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, OpenIdAuthService} -import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientError} +import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.identities.{Identities, IdentitiesImpl} -import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams.RealmSearchParams -import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceF} +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms -import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm -import io.circe.Json import izumi.distage.model.definition.{Id, ModuleDef} -import monix.bio.{IO, UIO} -import monix.execution.Scheduler +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ /** * Identities module wiring config. @@ -35,16 +27,7 @@ object IdentitiesModule extends ModuleDef { make[CacheConfig].from((cfg: AppConfig) => cfg.identities) make[Identities].fromEffect { (realms: Realms, hc: HttpClient @Id("realm"), config: CacheConfig) => - val findActiveRealm: String => UIO[Option[Realm]] = { (issuer: String) => - val pagination = FromPagination(0, 1000) - val params = RealmSearchParams(issuer = Some(issuer), deprecated = Some(false)) - val sort = ResourceF.defaultSort[Realm] - realms.list(pagination, params, sort).map { _.results.map(entry => entry.source.value).headOption }.toUIO - } - val getUserInfo: (Uri, OAuth2BearerToken) => IO[HttpClientError, Json] = { (uri: Uri, token: OAuth2BearerToken) => - hc.toJson(HttpRequest(uri = uri, headers = List(Authorization(token)))) - } - IdentitiesImpl(findActiveRealm, getUserInfo, config) + IdentitiesImpl(realms, hc, config).toUIO } make[OpenIdAuthService].from { (httpClient: HttpClient @Id("realm"), realms: Realms) => @@ -63,11 +46,10 @@ object IdentitiesModule extends ModuleDef { ( identities: Identities, aclCheck: AclCheck, - s: Scheduler, baseUri: BaseUri, cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering - ) => new IdentitiesRoutes(identities, aclCheck)(s, baseUri, cr, ordering) + ) => new IdentitiesRoutes(identities, aclCheck)(baseUri, cr, ordering) } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala index 189ed27c95..2cf8ce1c30 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala @@ -5,6 +5,7 @@ import akka.http.scaladsl.model.{HttpRequest, Uri} import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority import ch.epfl.bluebrain.nexus.delta.config.AppConfig +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering @@ -20,7 +21,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} import monix.execution.Scheduler -import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ /** * Realms module wiring config. @@ -46,11 +46,10 @@ object RealmsModule extends ModuleDef { realms: Realms, cfg: AppConfig, aclCheck: AclCheck, - s: Scheduler, cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering ) => - new RealmsRoutes(identities, realms, aclCheck)(cfg.http.baseUri, cfg.realms.pagination, s, cr, ordering) + new RealmsRoutes(identities, realms, aclCheck)(cfg.http.baseUri, cfg.realms.pagination, cr, ordering) } make[HttpClient].named("realm").from { (as: ActorSystem[Nothing], sc: Scheduler) => diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/SchemasModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/SchemasModule.scala index 77a497cf10..a9fcf0a567 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/SchemasModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/SchemasModule.scala @@ -27,7 +27,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.schemas.{SchemaImports, Schemas, Schema import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} -import monix.execution.Scheduler /** * Schemas wiring @@ -74,14 +73,12 @@ object SchemasModule extends ModuleDef { indexingAction: IndexingAction @Id("aggregate"), shift: Schema.Shift, baseUri: BaseUri, - s: Scheduler, cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering, fusionConfig: FusionConfig ) => new SchemasRoutes(identities, aclCheck, schemas, schemeDirectives, indexingAction(_, _, _)(shift, cr))( baseUri, - s, cr, ordering, fusionConfig diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/cache/LocalCache.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/cache/LocalCache.scala new file mode 100644 index 0000000000..7ad8cd41da --- /dev/null +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/cache/LocalCache.scala @@ -0,0 +1,183 @@ +package ch.epfl.bluebrain.nexus.delta.kernel.cache + +import cats.effect.IO +import com.github.benmanes.caffeine.cache.{Cache, Caffeine} + +import scala.concurrent.duration._ +import scala.jdk.CollectionConverters._ +import scala.jdk.DurationConverters._ + +/** + * An arbitrary key value store. + * + * @tparam K + * the key type + * @tparam V + * the value type + */ +trait LocalCache[K, V] { + + /** + * Adds the (key, value) to the store, replacing the current value if the key already exists. + * + * @param key + * the key under which the value is stored + * @param value + * the value stored + */ + def put(key: K, value: V): IO[Unit] + + /** + * Deletes a key from the store. + * + * @param key + * the key to be deleted from the store + */ + def remove(key: K): IO[Unit] + + /** + * @return + * all the entries in the store + */ + def entries: IO[Map[K, V]] + + /** + * @return + * a vector of all the values in the store + */ + def values: IO[Vector[V]] = entries.map(_.values.toVector) + + /** + * @param key + * the key + * @return + * an optional value for the provided key + */ + def get(key: K): IO[Option[V]] + + /** + * Fetch the value for the given key and if not, compute the new value, insert it in the store and return it This + * operation is not atomic. + * @param key + * the key + * @param op + * the computation yielding the value to associate with `key`, if `key` is previously unbound. + */ + def getOrElseUpdate(key: K, op: => IO[V]): IO[V] = + get(key).flatMap { + case Some(value) => IO.pure(value) + case None => + op.flatMap { newValue => + put(key, newValue).as(newValue) + } + } + + /** + * Fetch the value for the given key and if not, compute the new value, insert it in the store if defined and return + * it This operation is not atomic. + * @param key + * the key + * @param op + * the computation yielding the value to associate with `key`, if `key` is previously unbound. + */ + def getOrElseAttemptUpdate(key: K, op: => IO[Option[V]]): IO[Option[V]] = + get(key).flatMap { + case Some(value) => IO.pure(Some(value)) + case None => + op.flatMap { + case Some(newValue) => put(key, newValue).as(Some(newValue)) + case None => IO.none + } + } + + /** + * Tests whether the cache contains the given key. + * @param key + * the key to be tested + */ + def containsKey(key: K): IO[Boolean] = get(key).map(_.isDefined) + +} + +object LocalCache { + + /** + * Constructs a local key-value store + */ + final def apply[K, V](): IO[LocalCache[K, V]] = + IO.delay { + val cache: Cache[K, V] = + Caffeine + .newBuilder() + .build[K, V]() + new LocalCacheImpl(cache) + } + + /** + * Constructs a local key-value store following a LRU policy + * + * @param config + * the cache configuration + */ + final def lru[K, V](config: CacheConfig): IO[LocalCache[K, V]] = + lru(config.maxSize.toLong, config.expireAfter) + + /** + * Constructs a local key-value store following a LRU policy + * + * @param maxSize + * the max number of entries + * @param expireAfterAccess + * Entries will be removed one the givenduration has elapsed after the entry's creation, the most recent + * replacement of its value, or its last access. + */ + final def lru[K, V](maxSize: Long, expireAfterAccess: FiniteDuration = 1.hour): IO[LocalCache[K, V]] = + IO.delay { + val cache: Cache[K, V] = + Caffeine + .newBuilder() + .expireAfterAccess(expireAfterAccess.toJava) + .maximumSize(maxSize) + .build[K, V]() + new LocalCacheImpl(cache) + } + + /** + * Constructs a local key-value store + * + * @param config + * the cache configuration + */ + final def apply[K, V](config: CacheConfig): IO[LocalCache[K, V]] = + apply(config.maxSize.toLong, config.expireAfter) + + /** + * Constructs a local key-value store + * @param maxSize + * the max number of entries + * @param expireAfterWrite + * Entries will be removed one the givenduration has elapsed after the entry's creation or the most recent + * replacement of its value. + */ + final def apply[K, V](maxSize: Long, expireAfterWrite: FiniteDuration = 1.hour): IO[LocalCache[K, V]] = + IO.delay { + val cache: Cache[K, V] = + Caffeine + .newBuilder() + .expireAfterWrite(expireAfterWrite.toJava) + .maximumSize(maxSize) + .build[K, V]() + new LocalCacheImpl(cache) + } + + private class LocalCacheImpl[K, V](cache: Cache[K, V]) extends LocalCache[K, V] { + + override def put(key: K, value: V): IO[Unit] = IO.delay(cache.put(key, value)) + + override def get(key: K): IO[Option[V]] = IO.delay(Option(cache.getIfPresent(key))) + + override def remove(key: K): IO[Unit] = IO.delay(cache.invalidate(key)) + + override def entries: IO[Map[K, V]] = IO.delay(cache.asMap().asScala.toMap) + } +} diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala index effb2512ef..236b589b30 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala @@ -12,7 +12,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} -import monix.execution.Scheduler /** * Jira plugin wiring. @@ -31,7 +30,6 @@ class JiraPluginModule(priority: Int) extends ModuleDef { aclCheck: AclCheck, jiraClient: JiraClient, baseUri: BaseUri, - s: Scheduler, cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering ) => @@ -41,7 +39,6 @@ class JiraPluginModule(priority: Int) extends ModuleDef { jiraClient )( baseUri, - s, cr, ordering ) diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala index 32e1079476..1d91716092 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala @@ -1,18 +1,18 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira.routes -import cats.syntax.all._ import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{Directive1, Route} import cats.effect.IO -import ch.epfl.bluebrain.nexus.delta.plugins.jira.{JiraClient, JiraError} +import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.plugins.jira.model.{JiraResponse, Verifier} +import ch.epfl.bluebrain.nexus.delta.plugins.jira.{JiraClient, JiraError} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives -import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfMarshalling @@ -21,7 +21,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User import io.circe.JsonObject import io.circe.syntax.EncoderOps -import monix.execution.Scheduler /** * The Jira routes. @@ -37,7 +36,7 @@ class JiraRoutes( identities: Identities, aclCheck: AclCheck, jiraClient: JiraClient -)(implicit baseUri: BaseUri, s: Scheduler, cr: RemoteContextResolution, ordering: JsonKeyOrdering) +)(implicit baseUri: BaseUri, cr: RemoteContextResolution, ordering: JsonKeyOrdering) extends AuthDirectives(identities, aclCheck) with CirceUnmarshalling with RdfMarshalling { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala index 96f468cbd2..1354cc85f1 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala @@ -4,23 +4,25 @@ import akka.http.scaladsl.model.headers.OAuth2BearerToken import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.http.scaladsl.server.directives.Credentials +import cats.effect.IO +import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.kernel.Secret import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress import ch.epfl.bluebrain.nexus.delta.sdk.error.IdentityError.{AuthenticationFailed, InvalidToken} import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, ServiceAccount} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, ServiceAccount, TokenRejection} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject -import monix.execution.Scheduler +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ import scala.concurrent.Future /** * Akka HTTP directives for authentication */ -abstract class AuthDirectives(identities: Identities, aclCheck: AclCheck)(implicit val s: Scheduler) { +abstract class AuthDirectives(identities: Identities, aclCheck: AclCheck) { private def authenticator: AsyncAuthenticator[Caller] = { case Credentials.Missing => Future.successful(None) @@ -28,8 +30,11 @@ abstract class AuthDirectives(identities: Identities, aclCheck: AclCheck)(implic val cred = OAuth2BearerToken(token) identities .exchange(AuthToken(cred.token)) - .bimap(InvalidToken, Some(_)) - .runToFuture + .attemptNarrow[TokenRejection] + .flatMap { attempt => + IO.fromEither(attempt.bimap(InvalidToken, Some(_))) + } + .unsafeToFuture() } private def isBearerToken: Directive0 = @@ -61,7 +66,7 @@ abstract class AuthDirectives(identities: Identities, aclCheck: AclCheck)(implic * Checks whether given [[Caller]] has the [[Permission]] on the [[AclAddress]]. */ def authorizeFor(path: AclAddress, permission: Permission)(implicit caller: Caller): Directive0 = - authorizeAsync(aclCheck.authorizeFor(path, permission).runToFuture) or failWith(AuthorizationFailed) + authorizeAsync(toCatsIO(aclCheck.authorizeFor(path, permission)).unsafeToFuture()) or failWith(AuthorizationFailed) /** * Check whether [[Caller]] is the configured service account. diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/ResponseToMarshaller.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/ResponseToMarshaller.scala index 230eaf8598..e2cce0cebb 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/ResponseToMarshaller.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/ResponseToMarshaller.scala @@ -7,6 +7,7 @@ import akka.http.scaladsl.server.Directives.{complete, onSuccess, reject} import akka.http.scaladsl.server.Route import cats.effect.IO import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder @@ -16,7 +17,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.{HttpResponseFields, RdfMar import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ import monix.bio.{IO => BIO, UIO} import monix.execution.Scheduler -import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ trait ResponseToMarshaller { def apply(statusOverride: Option[StatusCode]): Route @@ -43,6 +43,21 @@ object ResponseToMarshaller extends RdfMarshalling { onSuccess(ioRoute.runToFuture)(identity) } + private[directives] def apply[E: JsonLdEncoder, A: ToEntityMarshaller]( + io: IO[Either[Response[E], Complete[A]]] + )(implicit cr: RemoteContextResolution, jo: JsonKeyOrdering): ResponseToMarshaller = + (statusOverride: Option[StatusCode]) => { + + val uioFinal = io.map(_.map(value => value.copy(status = statusOverride.getOrElse(value.status)))) + + val ioRoute = uioFinal.flatMap { + case Left(r: Reject[E]) => UIO.pure(reject(r)) + case Left(e: Complete[E]) => e.value.toCompactedJsonLd.map(r => complete(e.status, e.headers, r.json)) + case Right(v: Complete[A]) => UIO.pure(complete(v.status, v.headers, v.value)) + } + onSuccess(ioRoute.unsafeToFuture())(identity) + } + private[directives] type UseRight[A] = Either[Response[Unit], Complete[A]] implicit def uioEntityMarshaller[A: ToEntityMarshaller]( @@ -62,22 +77,22 @@ object ResponseToMarshaller extends RdfMarshalling { implicit def ioEntityMarshaller[E: JsonLdEncoder: HttpResponseFields, A: ToEntityMarshaller]( io: IO[Either[E, A]] - )(implicit s: Scheduler, cr: RemoteContextResolution, jo: JsonKeyOrdering): ResponseToMarshaller = { + )(implicit cr: RemoteContextResolution, jo: JsonKeyOrdering): ResponseToMarshaller = { val ioComplete = io.map { _.bimap( e => Complete(e), a => Complete(OK, Seq.empty, a) ) } - ResponseToMarshaller(ioComplete.toUIO) + ResponseToMarshaller(ioComplete) } implicit def ioResponseEntityMarshaller[E: JsonLdEncoder, A: ToEntityMarshaller]( io: IO[Either[Response[E], A]] - )(implicit s: Scheduler, cr: RemoteContextResolution, jo: JsonKeyOrdering): ResponseToMarshaller = { + )(implicit cr: RemoteContextResolution, jo: JsonKeyOrdering): ResponseToMarshaller = { val ioComplete = io.map { _.map(a => Complete(OK, Seq.empty, a)) } - ResponseToMarshaller(ioComplete.toUIO) + ResponseToMarshaller(ioComplete) } } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/Identities.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/Identities.scala index ef6f66f174..c8133ce177 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/Identities.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/Identities.scala @@ -1,7 +1,7 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, TokenRejection} -import monix.bio.IO +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} /** * Operations pertaining to authentication, token validation and identities. @@ -14,6 +14,6 @@ trait Identities { * @param token * a well formatted authentication token (usually a bearer token) */ - def exchange(token: AuthToken): IO[TokenRejection, Caller] + def exchange(token: AuthToken): IO[Caller] } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImpl.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImpl.scala index ef556efc53..729ba15d3a 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImpl.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImpl.scala @@ -1,17 +1,23 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities -import akka.http.scaladsl.model.headers.OAuth2BearerToken -import akka.http.scaladsl.model.{StatusCodes, Uri} +import akka.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} +import akka.http.scaladsl.model.{HttpRequest, StatusCodes, Uri} import cats.data.NonEmptySet -import cats.implicits._ +import cats.effect.IO +import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.kernel.Logger -import ch.epfl.bluebrain.nexus.delta.kernel.cache.{CacheConfig, KeyValueStore} +import ch.epfl.bluebrain.nexus.delta.kernel.cache.{CacheConfig, LocalCache} +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ import ch.epfl.bluebrain.nexus.delta.kernel.kamon.KamonMetricComponent -import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError +import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination +import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError.HttpClientStatusError import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesImpl.{extractGroups, logger, GroupsCache} import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.{GetGroupsFromOidcError, InvalidAccessToken, UnknownAccessTokenIssuer} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, TokenRejection} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceF +import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams.RealmSearchParams +import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, User} @@ -21,20 +27,19 @@ import com.nimbusds.jose.jwk.{JWK, JWKSet} import com.nimbusds.jose.proc.{JWSVerificationKeySelector, SecurityContext} import com.nimbusds.jwt.proc.{DefaultJWTClaimsVerifier, DefaultJWTProcessor} import io.circe.{Decoder, HCursor, Json} -import monix.bio.{IO, UIO} import scala.util.Try -class IdentitiesImpl private ( - findActiveRealm: String => UIO[Option[Realm]], - getUserInfo: (Uri, OAuth2BearerToken) => IO[HttpClientError, Json], +class IdentitiesImpl private[identities] ( + findActiveRealm: String => IO[Option[Realm]], + getUserInfo: (Uri, OAuth2BearerToken) => IO[Json], groups: GroupsCache ) extends Identities { import scala.jdk.CollectionConverters._ implicit private val kamonComponent: KamonMetricComponent = KamonMetricComponent("identities") - override def exchange(token: AuthToken): IO[TokenRejection, Caller] = { + override def exchange(token: AuthToken): IO[Caller] = { def realmKeyset(realm: Realm) = { val jwks = realm.keys.foldLeft(Set.empty[JWK]) { case (acc, e) => Try(JWK.parse(e.noSpaces)).map(acc + _).getOrElse(acc) @@ -56,7 +61,7 @@ class IdentitiesImpl private ( ) } - def fetchGroups(parsedToken: ParsedToken, realm: Realm): IO[TokenRejection, Set[Group]] = { + def fetchGroups(parsedToken: ParsedToken, realm: Realm): IO[Set[Group]] = { parsedToken.groups .map { s => IO.pure(s.map(Group(_, realm.label))) @@ -74,7 +79,7 @@ class IdentitiesImpl private ( val result = for { parsedToken <- IO.fromEither(ParsedToken.fromToken(token)) activeRealmOption <- findActiveRealm(parsedToken.issuer) - activeRealm <- IO.fromOption(activeRealmOption, UnknownAccessTokenIssuer) + activeRealm <- IO.fromOption(activeRealmOption)(UnknownAccessTokenIssuer) _ <- validate(activeRealm.acceptedAudiences, parsedToken, realmKeyset(activeRealm)) groups <- fetchGroups(parsedToken, activeRealm) } yield { @@ -82,20 +87,20 @@ class IdentitiesImpl private ( Caller(user, groups ++ Set(Anonymous, user, Authenticated(activeRealm.label))) } result.span("exchangeToken") - }.tapError { rejection => + }.onError { rejection => logger.debug(s"Extracting and validating the caller failed for the reason: $rejection") } } object IdentitiesImpl { - type GroupsCache = KeyValueStore[String, Set[Group]] + type GroupsCache = LocalCache[String, Set[Group]] - private val logger: Logger = Logger[this.type] + private val logger = Logger.cats[this.type] def extractGroups( - getUserInfo: (Uri, OAuth2BearerToken) => IO[HttpClientError, Json] - )(token: ParsedToken, realm: Realm): IO[TokenRejection, Option[Set[Group]]] = { + getUserInfo: (Uri, OAuth2BearerToken) => IO[Json] + )(token: ParsedToken, realm: Realm): IO[Option[Set[Group]]] = { def fromSet(cursor: HCursor): Decoder.Result[Set[String]] = cursor.get[Set[String]]("groups").map(_.map(_.trim).filterNot(_.isEmpty)) def fromCsv(cursor: HCursor): Decoder.Result[Set[String]] = @@ -105,7 +110,7 @@ object IdentitiesImpl { val stringGroups = fromSet(json.hcursor) orElse fromCsv(json.hcursor) getOrElse Set.empty[String] Some(stringGroups.map(str => Group(str, realm.label))) } - .onErrorHandleWith { + .handleErrorWith { case e: HttpClientStatusError if e.code == StatusCodes.Unauthorized || e.code == StatusCodes.Forbidden => val message = s"A provided client token was rejected by the OIDC provider for user '${token.subject}' of realm '${token.issuer}', reason: '${e.reason}'" @@ -119,19 +124,30 @@ object IdentitiesImpl { /** * Constructs a [[IdentitiesImpl]] instance - * @param findActiveRealm - * function to find the active realm matching the given issuer - * @param getUserInfo - * function to retrieve user info from the OIDC provider + * + * @param realms + * the realms instance + * @param hc + * the http client to retrieve groups * @param config - * the indentities configuration + * the cache configuration */ - def apply( - findActiveRealm: String => UIO[Option[Realm]], - getUserInfo: (Uri, OAuth2BearerToken) => IO[HttpClientError, Json], - config: CacheConfig - ): UIO[Identities] = - KeyValueStore.local(config).map { groups => + def apply(realms: Realms, hc: HttpClient, config: CacheConfig): IO[Identities] = { + val findActiveRealm: String => IO[Option[Realm]] = { (issuer: String) => + val pagination = FromPagination(0, 1000) + val params = RealmSearchParams(issuer = Some(issuer), deprecated = Some(false)) + val sort = ResourceF.defaultSort[Realm] + realms.list(pagination, params, sort).map { + _.results.map(entry => entry.source.value).headOption + } + } + val getUserInfo: (Uri, OAuth2BearerToken) => IO[Json] = { (uri: Uri, token: OAuth2BearerToken) => + hc.toJson(HttpRequest(uri = uri, headers = List(Authorization(token)))) + } + + LocalCache[String, Set[Group]](config).map { groups => new IdentitiesImpl(findActiveRealm, getUserInfo, groups) } + } + } diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectivesSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectivesSpec.scala index 1366e98b63..0971f4bde1 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectivesSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectivesSpec.scala @@ -4,6 +4,7 @@ import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.headers.{BasicHttpCredentials, OAuth2BearerToken} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{ExceptionHandler, Route} +import cats.effect.IO import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering @@ -11,17 +12,16 @@ import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, TokenRejection} -import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfExceptionHandler -import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller.Anonymous import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.InvalidAccessToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} +import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfExceptionHandler +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sdk.utils.RouteHelpers import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Subject, User} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import ch.epfl.bluebrain.nexus.testkit.{IOValues, TestHelpers} -import monix.bio.IO import monix.execution.Scheduler.Implicits.global import org.scalatest.matchers.should.Matchers @@ -46,7 +46,7 @@ class AuthDirectivesSpec extends RouteHelpers with TestHelpers with Matchers wit val identities = new Identities { - override def exchange(token: AuthToken): IO[TokenRejection, Caller] = { + override def exchange(token: AuthToken): IO[Caller] = { token match { case AuthToken("alice") => IO.pure(userCaller) case AuthToken("bob") => IO.pure(user2Caller) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesDummy.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesDummy.scala index 3c5eeb574f..6d62b2e221 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesDummy.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesDummy.scala @@ -1,16 +1,16 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities +import cats.effect.IO import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.InvalidAccessToken -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller, TokenRejection} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User -import monix.bio.IO /** * Dummy implementation of [[Identities]] passing the expected results in a map */ class IdentitiesDummy private (expected: Map[AuthToken, Caller]) extends Identities { - override def exchange(token: AuthToken): IO[TokenRejection, Caller] = + override def exchange(token: AuthToken): IO[Caller] = IO.fromEither( expected.get(token).toRight(InvalidAccessToken("Someone", "Some realm", "The caller could not be found.")) ) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSpec.scala deleted file mode 100644 index c0de6183e3..0000000000 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSpec.scala +++ /dev/null @@ -1,355 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.identities - -import akka.http.scaladsl.model.headers.OAuth2BearerToken -import akka.http.scaladsl.model.{HttpRequest, Uri} -import cats.data.NonEmptySet -import cats.implicits._ -import ch.epfl.bluebrain.nexus.delta.kernel.cache.CacheConfig -import ch.epfl.bluebrain.nexus.delta.sdk.generators.{RealmGen, WellKnownGen} -import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError.HttpUnexpectedError -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.{AccessTokenDoesNotContainAnIssuer, AccessTokenDoesNotContainSubject, GetGroupsFromOidcError, InvalidAccessToken, InvalidAccessTokenFormat, UnknownAccessTokenIssuer} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} -import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, User} -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label -import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap -import ch.epfl.bluebrain.nexus.testkit.{CirceLiteral, EitherValuable, IOValues, TestHelpers} -import com.nimbusds.jose.crypto.RSASSASigner -import com.nimbusds.jose.jwk.RSAKey -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator -import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} -import com.nimbusds.jwt.{JWTClaimsSet, PlainJWT, SignedJWT} -import io.circe.{parser, Json} -import monix.bio.{IO, UIO} -import monix.execution.Scheduler.Implicits.global -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpecLike - -import java.time.Instant -import java.util.Date -import scala.concurrent.duration._ -import scala.jdk.CollectionConverters._ - -class IdentitiesImplSpec - extends AnyWordSpecLike - with Matchers - with CirceLiteral - with TestHelpers - with IOFromMap - with IOValues - with EitherValuable { - - /** - * Generate RSA key - */ - def generateKeys: RSAKey = - new RSAKeyGenerator(2048) - .keyID(genString()) - .generate() - - /** - * Generate token - */ - def generateToken( - subject: String, - issuer: String, - rsaKey: RSAKey, - expires: Instant = Instant.now().plusSeconds(3600), - notBefore: Instant = Instant.now().minusSeconds(3600), - aud: Option[NonEmptySet[String]] = None, - groups: Option[Set[String]] = None, - useCommas: Boolean = false, - preferredUsername: Option[String] = None - ): AuthToken = { - val signer = new RSASSASigner(rsaKey.toPrivateKey) - val csb = new JWTClaimsSet.Builder() - .issuer(issuer) - .subject(subject) - .expirationTime(Date.from(expires)) - .notBeforeTime(Date.from(notBefore)) - - groups.foreach { set => - if (useCommas) csb.claim("groups", set.mkString(",")) - else csb.claim("groups", set.toArray) - } - - aud.foreach(audiences => csb.audience(audiences.toList.asJava)) - - preferredUsername.foreach(pu => csb.claim("preferred_username", pu)) - - val jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID).build(), csb.build()) - jwt.sign(signer) - AuthToken(jwt.serialize()) - } - - private val rsaKey = generateKeys - - private val signer = new RSASSASigner(rsaKey.toPrivateKey) - - private def toSignedJwt(builder: JWTClaimsSet.Builder): AuthToken = { - val jwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256) - .keyID(rsaKey.getKeyID) - .build(), - builder.build() - ) - jwt.sign(signer) - AuthToken(jwt.serialize()) - } - - private val githubLabel = Label.unsafe("github") - private val githubLabel2 = Label.unsafe("github2") - private val (githubOpenId, githubWk) = WellKnownGen.create(githubLabel.value) - private val (githubOpenId2, githubWk2) = WellKnownGen.create(githubLabel2.value) - - private val github = RealmGen - .realm(githubOpenId, githubWk) - .copy( - keys = Set(parser.parse(rsaKey.toPublicJWK.toJSONString).rightValue) - ) - - private val github2 = RealmGen - .realm(githubOpenId2, githubWk2, acceptedAudiences = Some(NonEmptySet.of("audience", "ba"))) - .copy( - keys = Set(parser.parse(rsaKey.toPublicJWK.toJSONString).rightValue) - ) - - private val gitlabLabel = Label.unsafe("gitlab") - private val (gitlabOpenId, gitlabWk) = WellKnownGen.create(gitlabLabel.value) - - private val gitlab = RealmGen - .realm(gitlabOpenId, gitlabWk) - .copy( - keys = Set(parser.parse(rsaKey.toPublicJWK.toJSONString).rightValue) - ) - - private val findActiveRealm: String => UIO[Option[Realm]] = ioFromMap[String, Realm]( - githubLabel.value -> github, - githubLabel2.value -> github2, - gitlabLabel.value -> gitlab - ) - - private def userInfo(uri: Uri): IO[HttpUnexpectedError, Json] = - ioFromMap( - Map( - github.userInfoEndpoint -> json"""{ "groups": ["group3", "group4"] }""" - ), - (_: Uri) => HttpUnexpectedError(HttpRequest(), "Error while getting response") - )(uri) - - private val identities: Identities = IdentitiesImpl( - findActiveRealm, - (uri: Uri, _: OAuth2BearerToken) => userInfo(uri), - CacheConfig(10, 2.minutes) - ).accepted - - "Identities" should { - - val auth = Authenticated(githubLabel) - val group1 = Group("group1", githubLabel) - val group2 = Group("group2", githubLabel) - val group3 = Group("group3", githubLabel) - val group4 = Group("group4", githubLabel) - - "correctly extract the caller" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - expires = expires, - groups = Some(Set("group1", "group2")), - preferredUsername = Some("Bob") - ) - - val user = User("Bob", githubLabel) - identities.exchange(token).accepted shouldEqual Caller(user, Set(user, Anonymous, auth, group1, group2)) - } - - "succeed when the token is valid and preferred user name is not set" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - expires = expires, - groups = Some(Set("group1", "group2")) - ) - - val user = User("Robert", githubLabel) - identities.exchange(token).accepted shouldEqual - Caller(user, Set(user, Anonymous, auth, group1, group2)) - } - - "succeed when the token is valid and groups are comma delimited" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - expires = expires, - groups = Some(Set("group1", "group2")), - useCommas = true - ) - - val user = User("Robert", githubLabel) - identities.exchange(token).accepted shouldEqual - Caller(user, Set(user, Anonymous, auth, group1, group2)) - } - - "succeed when the token is valid and groups are defined" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - expires = expires, - groups = None, - useCommas = true - ) - - val user = User("Robert", githubLabel) - identities.exchange(token).accepted shouldEqual - Caller(user, Set(user, Anonymous, auth, group3, group4)) - } - - "succeed when the token is valid and aud matches the available audiences" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel2.value, - rsaKey = rsaKey, - expires = expires, - aud = Some(NonEmptySet.of("ca", "ba")), - groups = Some(Set("group1", "group2")) - ) - - val user = User("Robert", githubLabel2) - val group1 = Group("group1", githubLabel2) - val group2 = Group("group2", githubLabel2) - identities.exchange(token).accepted shouldEqual - Caller(user, Set(user, Anonymous, Authenticated(githubLabel2), group1, group2)) - } - - "fail when the token is valid but aud does not match the available audiences" in { - val expires = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel2.value, - rsaKey = rsaKey, - expires = expires, - aud = Some(NonEmptySet.of("ca", "de")), - groups = Some(Set("group1", "group2")) - ) - - identities.exchange(token).rejected shouldEqual InvalidAccessToken( - "Robert", - githubLabel2.value, - "JWT audience rejected: [ca, de]" - ) - } - - "fail when the token is invalid" in { - identities.exchange(AuthToken(genString())).rejected shouldEqual InvalidAccessTokenFormat - } - - "fail when the token is not signed" in { - val csb = new JWTClaimsSet.Builder() - .subject("subject") - .expirationTime(Date.from(Instant.now().plusSeconds(3600))) - - identities - .exchange(AuthToken(new PlainJWT(csb.build()).serialize())) - .rejected shouldEqual InvalidAccessTokenFormat - } - - "fail when the token doesn't contain an issuer" in { - val csb = new JWTClaimsSet.Builder() - .subject("subject") - .expirationTime(Date.from(Instant.now().plusSeconds(3600))) - - identities.exchange(toSignedJwt(csb)).rejected shouldEqual AccessTokenDoesNotContainAnIssuer - } - - "fail when the token doesn't contain a subject" in { - val csb = new JWTClaimsSet.Builder() - .issuer(githubLabel.value) - .expirationTime(Date.from(Instant.now().plusSeconds(3600))) - - identities.exchange(toSignedJwt(csb)).rejected shouldEqual AccessTokenDoesNotContainSubject - } - - "fail when the token doesn't contain a known issuer" in { - val token = generateToken( - subject = "Robert", - issuer = "unoknown", - rsaKey = rsaKey, - groups = None, - useCommas = true - ) - - identities.exchange(token).rejected shouldEqual UnknownAccessTokenIssuer - } - - "fail when the token is expired" in { - val expires = Instant.now().minusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - expires = expires, - groups = None, - useCommas = true - ) - - identities.exchange(token).rejected shouldEqual InvalidAccessToken("Robert", githubLabel.value, "Expired JWT") - } - - "fail when the token is not yet valid" in { - val notBefore = Instant.now().plusSeconds(3600) - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = rsaKey, - notBefore = notBefore, - groups = None, - useCommas = true - ) - - identities.exchange(token).rejected shouldEqual InvalidAccessToken( - "Robert", - githubLabel.value, - "JWT before use time" - ) - } - - "fail when the signature is invalid" in { - val token = generateToken( - subject = "Robert", - issuer = githubLabel.value, - rsaKey = generateKeys, - groups = None, - useCommas = true - ) - - identities.exchange(token).rejected shouldEqual InvalidAccessToken( - "Robert", - githubLabel.value, - "Signed JWT rejected: Another algorithm expected, or no matching key(s) found" - ) - } - - "fail when getting groups from the oidc provider can't be complete" in { - val token = generateToken( - subject = "Robert", - issuer = gitlabLabel.value, - rsaKey = rsaKey, - groups = None, - useCommas = true - ) - - identities.exchange(token).rejected shouldEqual GetGroupsFromOidcError("Robert", gitlabLabel.value) - } - } - -} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSuite.scala new file mode 100644 index 0000000000..3071928685 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/IdentitiesImplSuite.scala @@ -0,0 +1,329 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.identities + +import akka.http.scaladsl.model.headers.OAuth2BearerToken +import akka.http.scaladsl.model.{HttpRequest, Uri} +import cats.data.NonEmptySet +import cats.effect.IO +import cats.implicits._ +import ch.epfl.bluebrain.nexus.delta.kernel.cache.LocalCache +import ch.epfl.bluebrain.nexus.delta.sdk.generators.{RealmGen, WellKnownGen} +import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError.HttpUnexpectedError +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.{AccessTokenDoesNotContainAnIssuer, AccessTokenDoesNotContainSubject, GetGroupsFromOidcError, InvalidAccessToken, InvalidAccessTokenFormat, UnknownAccessTokenIssuer} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, User} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.testkit.ce.{CatsEffectSuite, IOFromMap} +import ch.epfl.bluebrain.nexus.testkit.{CirceLiteral, TestHelpers} +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, PlainJWT, SignedJWT} +import io.circe.{parser, Json} + +import java.time.Instant +import java.util.Date +import scala.jdk.CollectionConverters._ + +class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMap with CirceLiteral { + + /** + * Generate RSA key + */ + def generateKeys: RSAKey = + new RSAKeyGenerator(2048) + .keyID(genString()) + .generate() + + private val nowMinus1h = Instant.now().minusSeconds(3600) + private val nowPlus1h = Instant.now().plusSeconds(3600) + + private val rsaKey = generateKeys + private val signer = new RSASSASigner(rsaKey.toPrivateKey) + private val publicKeys = Set(parser.parse(rsaKey.toPublicJWK.toJSONString).rightValue) + + /** + * Generate token + */ + def generateToken( + subject: String, + issuer: Label, + rsaKey: RSAKey = rsaKey, + expires: Instant = nowPlus1h, + notBefore: Instant = nowMinus1h, + aud: Option[NonEmptySet[String]] = None, + groups: Option[Set[String]] = None, + useCommas: Boolean = false, + preferredUsername: Option[String] = None + ): AuthToken = { + val csb = new JWTClaimsSet.Builder() + .issuer(issuer.value) + .subject(subject) + .expirationTime(Date.from(expires)) + .notBeforeTime(Date.from(notBefore)) + + groups.foreach { set => + if (useCommas) csb.claim("groups", set.mkString(",")) + else csb.claim("groups", set.toArray) + } + + aud.foreach(audiences => csb.audience(audiences.toList.asJava)) + + preferredUsername.foreach(pu => csb.claim("preferred_username", pu)) + + toSignedJwt(csb, rsaKey) + } + + private def toSignedJwt(builder: JWTClaimsSet.Builder, rsaKey: RSAKey = rsaKey): AuthToken = { + val jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaKey.getKeyID) + .build(), + builder.build() + ) + jwt.sign(signer) + AuthToken(jwt.serialize()) + } + + private val githubLabel = Label.unsafe("github") + private val githubLabel2 = Label.unsafe("github2") + private val (githubOpenId, githubWk) = WellKnownGen.create(githubLabel.value) + private val (githubOpenId2, githubWk2) = WellKnownGen.create(githubLabel2.value) + + private val github = RealmGen + .realm(githubOpenId, githubWk) + .copy(keys = publicKeys) + + private val github2 = RealmGen + .realm(githubOpenId2, githubWk2, acceptedAudiences = Some(NonEmptySet.of("audience", "ba"))) + .copy(keys = publicKeys) + + private val gitlabLabel = Label.unsafe("gitlab") + private val (gitlabOpenId, gitlabWk) = WellKnownGen.create(gitlabLabel.value) + + private val gitlab = RealmGen + .realm(gitlabOpenId, gitlabWk) + .copy( + keys = Set(parser.parse(rsaKey.toPublicJWK.toJSONString).rightValue) + ) + + private val findActiveRealm: String => IO[Option[Realm]] = ioFromMap[String, Realm]( + githubLabel.value -> github, + githubLabel2.value -> github2, + gitlabLabel.value -> gitlab + ) + + private def userInfo(uri: Uri): IO[Json] = + ioFromMap( + Map(github.userInfoEndpoint -> json"""{ "groups": ["group3", "group4"] }"""), + (_: Uri) => HttpUnexpectedError(HttpRequest(), "Error while getting response") + )(uri) + + private val identities: Identities = LocalCache[String, Set[Group]]() + .map { cache => + new IdentitiesImpl( + findActiveRealm, + (uri: Uri, _: OAuth2BearerToken) => userInfo(uri), + cache + ) + } + .unsafeRunSync() + + private val auth = Authenticated(githubLabel) + private val group1 = Group("group1", githubLabel) + private val group2 = Group("group2", githubLabel) + private val group3 = Group("group3", githubLabel) + private val group4 = Group("group4", githubLabel) + + test("Successfully extract the caller") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + expires = nowPlus1h, + groups = Some(Set("group1", "group2")), + preferredUsername = Some("Bob") + ) + + val user = User("Bob", githubLabel) + val expected = Caller(user, Set(user, Anonymous, auth, group1, group2)) + identities.exchange(token).assertEquals(expected) + } + + test("Succeed when the token is valid and preferred user name is not set") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + expires = nowPlus1h, + groups = Some(Set("group1", "group2")) + ) + + val user = User("Robert", githubLabel) + val expected = Caller(user, Set(user, Anonymous, auth, group1, group2)) + identities.exchange(token).assertEquals(expected) + } + + test("Succeed when the token is valid and groups are comma delimited") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + expires = nowPlus1h, + groups = Some(Set("group1", "group2")), + useCommas = true + ) + + val user = User("Robert", githubLabel) + val expected = Caller(user, Set(user, Anonymous, auth, group1, group2)) + identities.exchange(token).assertEquals(expected) + } + + test("Succeed when the token is valid and groups are defined") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + expires = nowPlus1h, + groups = None, + useCommas = true + ) + + val user = User("Robert", githubLabel) + val expected = Caller(user, Set(user, Anonymous, auth, group3, group4)) + identities.exchange(token).assertEquals(expected) + } + + test("Succeed when the token is valid and aud matches the available audiences") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel2, + rsaKey = rsaKey, + expires = nowPlus1h, + aud = Some(NonEmptySet.of("ca", "ba")), + groups = Some(Set("group1", "group2")) + ) + + val user = User("Robert", githubLabel2) + val group1 = Group("group1", githubLabel2) + val group2 = Group("group2", githubLabel2) + val expected = Caller(user, Set(user, Anonymous, Authenticated(githubLabel2), group1, group2)) + identities.exchange(token).assertEquals(expected) + } + + test("Fail when the token is valid but aud does not match the available audiences") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel2, + rsaKey = rsaKey, + expires = nowPlus1h, + aud = Some(NonEmptySet.of("ca", "de")), + groups = Some(Set("group1", "group2")) + ) + val expectedError = InvalidAccessToken("Robert", githubLabel2.value, "JWT audience rejected: [ca, de]") + identities.exchange(token).intercept(expectedError) + } + + test("Fail when the token is invalid") { + identities.exchange(AuthToken(genString())).intercept(InvalidAccessTokenFormat) + } + + test("Fail when the token is not signed") { + val csb = new JWTClaimsSet.Builder() + .subject("subject") + .expirationTime(Date.from(nowPlus1h)) + + val token = AuthToken(new PlainJWT(csb.build()).serialize()) + identities.exchange(token).intercept(InvalidAccessTokenFormat) + } + + test("Fail when the token doesn't contain an issuer") { + val csb = new JWTClaimsSet.Builder() + .subject("subject") + .expirationTime(Date.from(nowPlus1h)) + + val token = toSignedJwt(csb) + identities.exchange(token).intercept(AccessTokenDoesNotContainAnIssuer) + } + + test("Fail when the token doesn't contain a subject") { + val csb = new JWTClaimsSet.Builder() + .issuer(githubLabel.value) + .expirationTime(Date.from(nowPlus1h)) + + val token = toSignedJwt(csb) + identities.exchange(token).intercept(AccessTokenDoesNotContainSubject) + } + + test("Fail when the token doesn't contain a known issuer") { + val token = generateToken( + subject = "Robert", + issuer = Label.unsafe("unknown"), + rsaKey = rsaKey, + groups = None, + useCommas = true + ) + + identities.exchange(token).intercept(UnknownAccessTokenIssuer) + } + + test("Fail when the token is expired") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + expires = nowMinus1h, + groups = None, + useCommas = true + ) + + val expectedError = InvalidAccessToken("Robert", githubLabel.value, "Expired JWT") + identities.exchange(token).intercept(expectedError) + } + + test("Fail when the token is not yet valid") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = rsaKey, + notBefore = nowPlus1h, + groups = None, + useCommas = true + ) + + val expectedError = InvalidAccessToken("Robert", githubLabel.value, "JWT before use time") + identities.exchange(token).intercept(expectedError) + } + + test("Fail when the signature is invalid") { + val token = generateToken( + subject = "Robert", + issuer = githubLabel, + rsaKey = generateKeys, + groups = None, + useCommas = true + ) + + val expectedError = InvalidAccessToken( + "Robert", + githubLabel.value, + "Signed JWT rejected: Another algorithm expected, or no matching key(s) found" + ) + identities.exchange(token).intercept(expectedError) + } + + test("Fail when getting groups from the oidc provider can't be complete") { + val token = generateToken( + subject = "Robert", + issuer = gitlabLabel, + rsaKey = rsaKey, + groups = None, + useCommas = true + ) + + val expectedError = GetGroupsFromOidcError("Robert", gitlabLabel.value) + identities.exchange(token).intercept(expectedError) + } + +}