diff --git a/build.sbt b/build.sbt index 76a4570a4c..222fa0e602 100755 --- a/build.sbt +++ b/build.sbt @@ -118,6 +118,7 @@ lazy val monixEval = "io.monix" %% "monix-eval" lazy val munit = "org.scalameta" %% "munit" % munitVersion lazy val nimbusJoseJwt = "com.nimbusds" % "nimbus-jose-jwt" % nimbusJoseJwtVersion lazy val pureconfig = "com.github.pureconfig" %% "pureconfig" % pureconfigVersion +lazy val pureconfigCats = "com.github.pureconfig" %% "pureconfig-cats" % pureconfigVersion lazy val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion lazy val scalaTest = "org.scalatest" %% "scalatest" % scalaTestVersion lazy val scalaXml = "org.scala-lang.modules" %% "scala-xml" % scalaXmlVersion @@ -207,14 +208,17 @@ lazy val kernel = project akkaActorTyped, // Needed to create content type akkaHttpCore, caffeine, + catsCore, catsRetry, circeCore, circeParser, handleBars, monixBio, + nimbusJoseJwt, kamonCore, log4cats, pureconfig, + pureconfigCats, scalaLogging, munit % Test, scalaTest % Test @@ -257,7 +261,6 @@ lazy val sourcingPsql = project .settings(shared, compilation, assertJavaVersion, coverage, release) .settings( libraryDependencies ++= Seq( - catsCore, circeCore, circeGenericExtras, circeParser, @@ -324,7 +327,6 @@ lazy val sdk = project distageCore, fs2, monixBio, - nimbusJoseJwt, akkaTestKitTyped % Test, akkaHttpTestKit % Test, munit % Test, @@ -735,7 +737,7 @@ lazy val storage = project servicePackaging, coverageMinimumStmtTotal := 75 ) - .dependsOn(kernel) + .dependsOn(kernel, testkit % "test->compile") .settings(cargo := { import scala.sys.process._ diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/AuthToken.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/AuthToken.scala similarity index 94% rename from delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/AuthToken.scala rename to delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/AuthToken.scala index c76f574504..129bf325b5 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/AuthToken.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/AuthToken.scala @@ -1,4 +1,4 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.identities.model +package ch.epfl.bluebrain.nexus.delta.kernel.jwt import akka.http.scaladsl.model.headers.OAuth2BearerToken import io.circe.{Decoder, Encoder} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/ParsedToken.scala similarity index 61% rename from delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala rename to delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/ParsedToken.scala index 13e13e00b5..a929380b12 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/ParsedToken.scala @@ -1,11 +1,18 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.identities +package ch.epfl.bluebrain.nexus.delta.kernel.jwt -import cats.implicits._ -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, TokenRejection} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection._ +import cats.data.NonEmptySet +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection._ +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWKSet +import ch.epfl.bluebrain.nexus.delta.kernel.syntax._ +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.proc.{JWSVerificationKeySelector, SecurityContext} +import com.nimbusds.jwt.proc.{DefaultJWTClaimsVerifier, DefaultJWTProcessor} import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} import java.time.Instant +import scala.jdk.CollectionConverters._ import scala.util.Try /** @@ -18,7 +25,21 @@ final case class ParsedToken private ( expirationTime: Instant, groups: Option[Set[String]], jwtToken: SignedJWT -) +) { + + def validate(audiences: Option[NonEmptySet[String]], keySet: JWKSet): Either[InvalidAccessToken, JWTClaimsSet] = { + val proc = new DefaultJWTProcessor[SecurityContext] + val keySelector = new JWSVerificationKeySelector(JWSAlgorithm.RS256, new ImmutableJWKSet[SecurityContext](keySet)) + proc.setJWSKeySelector(keySelector) + audiences.foreach { aud => + proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier(aud.toSet.asJava, null, null, null)) + } + Either + .catchNonFatal(proc.process(jwtToken, null)) + .leftMap(err => InvalidAccessToken(subject, issuer, err.getMessage)) + } + +} object ParsedToken { @@ -33,13 +54,13 @@ object ParsedToken { def parseJwt: Either[TokenRejection, SignedJWT] = Either .catchNonFatal(SignedJWT.parse(token.value)) - .leftMap(_ => InvalidAccessTokenFormat) + .leftMap { e => InvalidAccessTokenFormat(e.getMessage) } def claims(jwt: SignedJWT): Either[TokenRejection, JWTClaimsSet] = Either .catchNonFatal(jwt.getJWTClaimsSet) - .filterOrElse(_ != null, InvalidAccessTokenFormat) - .leftMap(_ => InvalidAccessTokenFormat) + .leftMap { e => InvalidAccessTokenFormat(e.getMessage) } + .filterOrElse(_ != null, InvalidAccessTokenFormat("No claim is defined.")) def subject(claimsSet: JWTClaimsSet) = { val preferredUsername = Try(claimsSet.getStringClaim("preferred_username")) diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/TokenRejection.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/TokenRejection.scala new file mode 100644 index 0000000000..29e0d51ca4 --- /dev/null +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/jwt/TokenRejection.scala @@ -0,0 +1,55 @@ +package ch.epfl.bluebrain.nexus.delta.kernel.jwt + +/** + * Enumeration of token rejections. + * + * @param reason + * a descriptive message for reasons why a token is rejected by the system + */ +// $COVERAGE-OFF$ +sealed abstract class TokenRejection(reason: String) extends Exception with Product with Serializable { + override def fillInStackTrace(): Throwable = this + override def getMessage: String = reason +} + +object TokenRejection { + + /** + * Rejection for cases where the AccessToken is not a properly formatted signed JWT. + */ + final case class InvalidAccessTokenFormat(details: String) + extends TokenRejection( + s"Access token is invalid; possible causes are: JWT not signed, encoded parts are not properly encoded or each part is not a valid json, details: '$details'" + ) + + /** + * Rejection for cases where the access token does not contain a subject in the claim set. + */ + final case object AccessTokenDoesNotContainSubject extends TokenRejection("The token doesn't contain a subject.") + + /** + * Rejection for cases where the access token does not contain an issuer in the claim set. + */ + final case object AccessTokenDoesNotContainAnIssuer extends TokenRejection("The token doesn't contain an issuer.") + + /** + * Rejection for cases where the issuer specified in the access token claim set is unknown; also applies to issuers + * of deprecated realms. + */ + final case object UnknownAccessTokenIssuer extends TokenRejection("The issuer referenced in the token was not found.") + + /** + * Rejection for cases where the access token is invalid according to JWTClaimsVerifier + */ + final case class InvalidAccessToken(subject: String, issuer: String, details: String) + extends TokenRejection(s"The provided token is invalid for user '$subject/$issuer' .") + + /** + * Rejection for cases where we couldn't fetch the groups from the OIDC provider + */ + final case class GetGroupsFromOidcError(subject: String, issuer: String) + extends TokenRejection( + "The token is invalid; possible causes are: the OIDC provider is unreachable." + ) +} +// $COVERAGE-ON$ diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/NonEmptySetSyntax.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/NonEmptySetSyntax.scala similarity index 92% rename from delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/NonEmptySetSyntax.scala rename to delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/NonEmptySetSyntax.scala index 99d4d9a9a9..aa71ec8b52 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/NonEmptySetSyntax.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/NonEmptySetSyntax.scala @@ -1,4 +1,4 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.syntax +package ch.epfl.bluebrain.nexus.delta.kernel.syntax import cats.data.NonEmptySet diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/package.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/package.scala index 5874c3e5ce..1661242189 100644 --- a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/package.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/syntax/package.scala @@ -1,3 +1,3 @@ package ch.epfl.bluebrain.nexus.delta.kernel -package object syntax extends KamonSyntax with ClassTagSyntax with IOSyntax with InstantSyntax +package object syntax extends KamonSyntax with ClassTagSyntax with IOSyntax with InstantSyntax with NonEmptySetSyntax diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala index 31f9ed3691..2e84f0b0f6 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala @@ -4,10 +4,9 @@ import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.kernel.Logger import ch.epfl.bluebrain.nexus.delta.kernel.cache.LocalCache import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, ParsedToken} import ch.epfl.bluebrain.nexus.delta.kernel.utils.IOInstant import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials.ClientCredentials -import ch.epfl.bluebrain.nexus.delta.sdk.identities.ParsedToken -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken import monix.bio import java.time.{Duration, Instant} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala index 38685f73e2..410312e4cf 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala @@ -7,11 +7,10 @@ import akka.http.scaladsl.model.{HttpRequest, Uri} import cats.effect.IO import ch.epfl.bluebrain.nexus.delta.kernel.Secret import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, ParsedToken} import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials.ClientCredentials import ch.epfl.bluebrain.nexus.delta.sdk.error.AuthTokenError.{AuthTokenHttpError, AuthTokenNotFoundInResponse, RealmIsDeprecated} import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient -import ch.epfl.bluebrain.nexus.delta.sdk.identities.ParsedToken -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken 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.sourcing.model.Label 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 a79cd85448..292255eb60 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 @@ -12,10 +12,11 @@ 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, TokenRejection} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{Caller, ServiceAccount} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, TokenRejection} import scala.concurrent.Future diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala index 598139952b..b5c06de8ca 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala @@ -1,11 +1,17 @@ package ch.epfl.bluebrain.nexus.delta.sdk.error +import akka.http.scaladsl.model.StatusCodes +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection.InvalidAccessToken +import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClassUtils +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.BNode import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection -import io.circe.syntax._ +import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields +import ch.epfl.bluebrain.nexus.delta.sdk.syntax.httpResponseFieldsSyntax +import io.circe.syntax.EncoderOps import io.circe.{Encoder, JsonObject} /** @@ -34,6 +40,19 @@ object IdentityError { */ final case class InvalidToken(rejection: TokenRejection) extends IdentityError(rejection.getMessage) + implicit val tokenRejectionEncoder: Encoder.AsObject[TokenRejection] = + Encoder.AsObject.instance { r => + val tpe = ClassUtils.simpleName(r) + val json = JsonObject.empty.add(keywords.tpe, tpe.asJson).add("reason", r.getMessage.asJson) + r match { + case InvalidAccessToken(_, _, error) => json.add("details", error.asJson) + case _ => json + } + } + + implicit final val tokenRejectionJsonLdEncoder: JsonLdEncoder[TokenRejection] = + JsonLdEncoder.computeFromCirce(id = BNode.random, ctx = ContextValue(contexts.error)) + implicit val identityErrorEncoder: Encoder.AsObject[IdentityError] = Encoder.AsObject.instance[IdentityError] { case InvalidToken(r) => @@ -44,4 +63,13 @@ object IdentityError { implicit val identityErrorJsonLdEncoder: JsonLdEncoder[IdentityError] = JsonLdEncoder.computeFromCirce(ContextValue(contexts.error)) + + implicit val responseFieldsTokenRejection: HttpResponseFields[TokenRejection] = + HttpResponseFields(_ => StatusCodes.Unauthorized) + + implicit val responseFieldsIdentities: HttpResponseFields[IdentityError] = + HttpResponseFields { + case IdentityError.AuthenticationFailed => StatusCodes.Unauthorized + case IdentityError.InvalidToken(rejection) => rejection.status + } } 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 c8133ce177..297148e29f 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,8 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities import cats.effect.IO -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.AuthToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller /** * Operations pertaining to authentication, token validation and identities. 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 fe9200ecae..f0e31b314b 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 @@ -2,30 +2,27 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities import akka.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} import akka.http.scaladsl.model.{HttpRequest, StatusCodes, Uri} -import cats.data.{NonEmptySet, OptionT} +import cats.data.OptionT 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, LocalCache} import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection.{GetGroupsFromOidcError, InvalidAccessToken, UnknownAccessTokenIssuer} +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, ParsedToken} import ch.epfl.bluebrain.nexus.delta.kernel.kamon.KamonMetricComponent 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, RealmCache} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection.{GetGroupsFromOidcError, InvalidAccessToken, UnknownAccessTokenIssuer} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{AuthToken, Caller} +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.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} -import com.nimbusds.jose.JWSAlgorithm -import com.nimbusds.jose.jwk.source.ImmutableJWKSet 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 scala.util.Try @@ -48,20 +45,6 @@ class IdentitiesImpl private[identities] ( new JWKSet(jwks.toList.asJava) } - def validate(audiences: Option[NonEmptySet[String]], token: ParsedToken, keySet: JWKSet) = { - val proc = new DefaultJWTProcessor[SecurityContext] - val keySelector = new JWSVerificationKeySelector(JWSAlgorithm.RS256, new ImmutableJWKSet[SecurityContext](keySet)) - proc.setJWSKeySelector(keySelector) - audiences.foreach { aud => - proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier(aud.toSet.asJava, null, null, null)) - } - IO.fromEither( - Either - .catchNonFatal(proc.process(token.jwtToken, null)) - .leftMap(err => InvalidAccessToken(token.subject, token.issuer, err.getMessage)) - ) - } - def fetchRealm(parsedToken: ParsedToken): IO[Realm] = { val getRealm = realm.getOrElseAttemptUpdate(parsedToken.rawToken, findActiveRealm(parsedToken.issuer)) OptionT(getRealm).getOrRaise(UnknownAccessTokenIssuer) @@ -85,7 +68,7 @@ class IdentitiesImpl private[identities] ( val result = for { parsedToken <- IO.fromEither(ParsedToken.fromToken(token)) activeRealm <- fetchRealm(parsedToken) - _ <- validate(activeRealm.acceptedAudiences, parsedToken, realmKeyset(activeRealm)) + _ <- IO.fromEither(parsedToken.validate(activeRealm.acceptedAudiences, realmKeyset(activeRealm))) groups <- fetchGroups(parsedToken, activeRealm) } yield { val user = User(parsedToken.subject, activeRealm.label) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala deleted file mode 100644 index e4855bafcb..0000000000 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala +++ /dev/null @@ -1,80 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.identities.model - -import akka.http.scaladsl.model.StatusCodes -import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClassUtils -import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.BNode -import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder -import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError -import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields -import io.circe.syntax._ -import io.circe.{Encoder, JsonObject} - -/** - * Enumeration of token rejections. - * - * @param reason - * a descriptive message for reasons why a token is rejected by the system - */ -sealed abstract class TokenRejection(reason: String) extends SDKError with Product with Serializable { - override def getMessage: String = reason -} - -object TokenRejection { - - /** - * Rejection for cases where the AccessToken is not a properly formatted signed JWT. - */ - final case object InvalidAccessTokenFormat - extends TokenRejection( - "Access token is invalid; possible causes are: JWT not signed, encoded parts are not properly encoded or each part is not a valid json." - ) - - /** - * Rejection for cases where the access token does not contain a subject in the claim set. - */ - final case object AccessTokenDoesNotContainSubject extends TokenRejection("The token doesn't contain a subject.") - - /** - * Rejection for cases where the access token does not contain an issuer in the claim set. - */ - final case object AccessTokenDoesNotContainAnIssuer extends TokenRejection("The token doesn't contain an issuer.") - - /** - * Rejection for cases where the issuer specified in the access token claim set is unknown; also applies to issuers - * of deprecated realms. - */ - final case object UnknownAccessTokenIssuer extends TokenRejection("The issuer referenced in the token was not found.") - - /** - * Rejection for cases where the access token is invalid according to JWTClaimsVerifier - */ - final case class InvalidAccessToken(subject: String, issuer: String, details: String) - extends TokenRejection(s"The provided token is invalid for user '$subject/$issuer' .") - - /** - * Rejection for cases where we couldn't fetch the groups from the OIDC provider - */ - final case class GetGroupsFromOidcError(subject: String, issuer: String) - extends TokenRejection( - "The token is invalid; possible causes are: the OIDC provider is unreachable." - ) - - implicit val tokenRejectionEncoder: Encoder.AsObject[TokenRejection] = - Encoder.AsObject.instance { r => - val tpe = ClassUtils.simpleName(r) - val json = JsonObject.empty.add(keywords.tpe, tpe.asJson).add("reason", r.getMessage.asJson) - r match { - case InvalidAccessToken(_, _, error) => json.add("details", error.asJson) - case _ => json - } - } - - implicit final val tokenRejectionJsonLdEncoder: JsonLdEncoder[TokenRejection] = - JsonLdEncoder.computeFromCirce(id = BNode.random, ctx = ContextValue(contexts.error)) - - implicit val responseFieldsTokenRejection: HttpResponseFields[TokenRejection] = - HttpResponseFields(_ => StatusCodes.Unauthorized) -} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/HttpResponseFields.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/HttpResponseFields.scala index 0f3f8f0ee1..c87c448621 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/HttpResponseFields.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/HttpResponseFields.scala @@ -1,9 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.sdk.marshalling import akka.http.scaladsl.model.{HttpHeader, StatusCode, StatusCodes} +import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.{AuthorizationFailed, FetchContextFailed, IndexingFailed, ScopeInitializationFailed, UnknownSseLabel} -import ch.epfl.bluebrain.nexus.delta.sdk.error.{IdentityError, ServiceError} -import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ /** * Typeclass definition for ''A''s from which the HttpHeaders and StatusCode can be ontained. @@ -61,12 +60,6 @@ object HttpResponseFields { override def headersFrom(value: A): Seq[HttpHeader] = f(value)._2 } - implicit val responseFieldsIdentities: HttpResponseFields[IdentityError] = - HttpResponseFields { - case IdentityError.AuthenticationFailed => StatusCodes.Unauthorized - case IdentityError.InvalidToken(rejection) => rejection.status - } - implicit val responseFieldsServiceError: HttpResponseFields[ServiceError] = HttpResponseFields { case AuthorizationFailed => StatusCodes.Forbidden diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/HttpRequestSyntax.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/HttpRequestSyntax.scala index b07fbe7043..39be8ea7f7 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/HttpRequestSyntax.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/HttpRequestSyntax.scala @@ -2,7 +2,7 @@ package ch.epfl.bluebrain.nexus.delta.sdk.syntax import akka.http.scaladsl.model.HttpRequest import akka.http.scaladsl.model.headers.{HttpCredentials, OAuth2BearerToken} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.AuthToken trait HttpRequestSyntax { implicit final def httpRequestSyntax(req: HttpRequest): HttpRequestOpts = new HttpRequestOpts(req) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/package.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/package.scala index f9ceadbbb5..c89d84aed5 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/package.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/syntax/package.scala @@ -21,5 +21,4 @@ package object syntax with ClassTagSyntax with IOSyntax with InstantSyntax - with NonEmptySetSyntax with ProjectionErrorsSyntax 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 0971f4bde1..f3169950b8 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 @@ -5,6 +5,7 @@ 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.kernel.jwt.AuthToken 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 @@ -13,8 +14,8 @@ 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.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.kernel.jwt.TokenRejection.InvalidAccessToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.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 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 6d62b2e221..093625dc05 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,8 +1,9 @@ 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} +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.AuthToken +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection.InvalidAccessToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User /** 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 index 7e000063db..229980d519 100644 --- 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 @@ -7,26 +7,26 @@ import cats.effect.IO import cats.effect.concurrent.Ref import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.kernel.cache.LocalCache +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, ParsedToken} +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection._ 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.IdentitiesImpl.{GroupsCache, RealmCache} -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.identities.model.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.jwt.TokenGenerator 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 com.nimbusds.jwt.{JWTClaimsSet, PlainJWT} 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 { @@ -58,35 +58,17 @@ class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMa 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()) - } + ): AuthToken = TokenGenerator.generateToken( + subject, + issuer.value, + rsaKey, + expires, + notBefore, + aud, + groups, + useCommas, + preferredUsername + ) private val githubLabel = Label.unsafe("github") private val githubLabel2 = Label.unsafe("github2") @@ -236,7 +218,7 @@ class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMa } test("Fail when the token is invalid") { - identities.exchange(AuthToken(genString())).intercept(InvalidAccessTokenFormat) + identities.exchange(AuthToken(genString())).intercept[InvalidAccessTokenFormat] } test("Fail when the token is not signed") { @@ -245,7 +227,7 @@ class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMa .expirationTime(Date.from(nowPlus1h)) val token = AuthToken(new PlainJWT(csb.build()).serialize()) - identities.exchange(token).intercept(InvalidAccessTokenFormat) + identities.exchange(token).intercept[InvalidAccessTokenFormat] } test("Fail when the token doesn't contain an issuer") { @@ -253,7 +235,7 @@ class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMa .subject("subject") .expirationTime(Date.from(nowPlus1h)) - val token = toSignedJwt(csb) + val token = TokenGenerator.toSignedJwt(csb, rsaKey, signer) identities.exchange(token).intercept(AccessTokenDoesNotContainAnIssuer) } @@ -262,7 +244,7 @@ class IdentitiesImplSuite extends CatsEffectSuite with TestHelpers with IOFromMa .issuer(githubLabel.value) .expirationTime(Date.from(nowPlus1h)) - val token = toSignedJwt(csb) + val token = TokenGenerator.toSignedJwt(csb, rsaKey, signer) identities.exchange(token).intercept(AccessTokenDoesNotContainSubject) } diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala index b6dc1cf571..5c36371daf 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala @@ -1,13 +1,14 @@ package ch.epfl.bluebrain.nexus.delta.sdk.identities.model import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.{contexts, nxv} -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.TokenRejection._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection._ import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ import ch.epfl.bluebrain.nexus.delta.sdk.utils.Fixtures import ch.epfl.bluebrain.nexus.testkit.{CirceLiteral, IOValues, TestHelpers} import org.scalatest.Inspectors import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike +import ch.epfl.bluebrain.nexus.delta.sdk.error.IdentityError._ class TokenRejectionSpec extends AnyWordSpecLike @@ -20,7 +21,7 @@ class TokenRejectionSpec "A TokenRejection" should { - val invalidFormat = InvalidAccessTokenFormat + val invalidFormat = InvalidAccessTokenFormat("Details") val noIssuer = AccessTokenDoesNotContainSubject "be converted to compacted JSON-LD" in { diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/jwt/TokenGenerator.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/jwt/TokenGenerator.scala new file mode 100644 index 0000000000..a5c81c2943 --- /dev/null +++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/jwt/TokenGenerator.scala @@ -0,0 +1,58 @@ +package ch.epfl.bluebrain.nexus.testkit.jwt + +import cats.data.NonEmptySet +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.AuthToken +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} + +import java.time.Instant +import java.util.Date + +object TokenGenerator { + + import scala.jdk.CollectionConverters._ + + def generateToken( + subject: String, + issuer: String, + rsaKey: RSAKey, + expires: Instant, + notBefore: Instant, + 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) + .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, new RSASSASigner(rsaKey.toPrivateKey)) + } + + def toSignedJwt(builder: JWTClaimsSet.Builder, rsaKey: RSAKey, signer: RSASSASigner): AuthToken = { + val jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaKey.getKeyID) + .build(), + builder.build() + ) + jwt.sign(signer) + AuthToken(jwt.serialize()) + } + +} diff --git a/storage/src/main/resources/app.conf b/storage/src/main/resources/app.conf index 27ff92a2db..711833fa4b 100644 --- a/storage/src/main/resources/app.conf +++ b/storage/src/main/resources/app.conf @@ -64,27 +64,20 @@ app { } # Allowed subject to perform calls - subject { - # flag to decide whether or not the allowed subject is Anonymous or a User - anonymous = false + authorization { + # flag to decide whether a token is expected or not to accept the incoming requests + # valid values: "anonymous" or "verify-token" + method = anonymous # the user realm. It must be present when anonymous = false and it must be removed when anonymous = true - //realm = "realm" + # issuer = "realm" # the user name. It must be present when anonymous = false and it must be removed when anonymous = true - //name = "username" + # subject = "username" + # the optional set of audiences of the realm + # audiences = [ ] + # Public JWK keys to validate the incoming token + # keys = [ "key" ] } - # Delta client configuration - delta { - # The public iri to the Delta service - public-iri = "http://localhost:8080" - # The internal iri to the Delta service - internal-iri = "http://localhost:8080" - # The version prefix - prefix = "v1" - - # The delay for retrying after completion on SSE - sse-retry-delay = 1 second - } # monitoring config monitoring { # tracing settings diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClient.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClient.scala deleted file mode 100644 index ec961b39ed..0000000000 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClient.scala +++ /dev/null @@ -1,175 +0,0 @@ -package ch.epfl.bluebrain.nexus.storage - -import akka.actor.ActorSystem -import akka.http.scaladsl.Http -import akka.http.scaladsl.client.RequestBuilding.Get -import akka.http.scaladsl.model.{HttpRequest, Uri} -import akka.http.scaladsl.model.headers.OAuth2BearerToken -import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller -import akka.util.ByteString -import cats.effect.{ContextShift, Effect, IO} -import cats.implicits._ -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Identity._ -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient._ -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClientError.IdentitiesSerializationError -import ch.epfl.bluebrain.nexus.storage.config.DeltaClientConfig -import de.heikoseeberger.akkahttpcirce.ErrorAccumulatingCirceSupport.{DecodingFailures => AccDecodingFailures} -import io.circe.Decoder.Result -import io.circe.{Decoder, DecodingFailure, HCursor} - -import scala.concurrent.ExecutionContext - -class DeltaIdentitiesClient[F[_]](config: DeltaClientConfig)(implicit F: Effect[F], as: ActorSystem) - extends JsonLdCirceSupport { - - private val um: FromEntityUnmarshaller[Caller] = unmarshaller[Caller] - implicit private val ec: ExecutionContext = as.dispatcher - implicit private val contextShift: ContextShift[IO] = IO.contextShift(ec) - - def apply()(implicit credentials: Option[AccessToken]): F[Caller] = - credentials match { - case Some(token) => - execute(Get(Uri(config.identitiesIri.toString)).addCredentials(OAuth2BearerToken(token.value))) - case None => - F.pure(Caller.anonymous) - } - - private def execute(req: HttpRequest): F[Caller] = { - IO.fromFuture(IO(Http().singleRequest(req))).to[F].flatMap { resp => - if (resp.status.isSuccess()) - IO.fromFuture(IO(um(resp.entity))).to[F].recoverWith { - case err: AccDecodingFailures => F.raiseError(IdentitiesSerializationError(err.getMessage)) - case err: Error => F.raiseError(IdentitiesSerializationError(err.getMessage)) - } - else - IO.fromFuture(IO(resp.entity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.utf8String))) - .to[F] - .flatMap { err => F.raiseError(DeltaIdentitiesClientError.unsafe(resp.status, err)) } - } - } - -} - -object DeltaIdentitiesClient { - - /** - * The client caller. It contains the subject and the list of identities (which contains the subject again) - * - * @param subject - * the identity that performed the call - * @param identities - * the set of other identities associated to the ''subject''. E.g.: groups, anonymous, authenticated - */ - final case class Caller(subject: Subject, identities: Set[Identity]) - - object Caller { - - /** - * An anonymous caller - */ - val anonymous: Caller = Caller(Anonymous: Subject, Set[Identity](Anonymous)) - - implicit final val callerDecoder: Decoder[Caller] = - Decoder.instance { cursor => - cursor - .get[Set[Identity]]("identities") - .flatMap { identities => - identities.collectFirst { case u: User => u } orElse identities.collectFirst { case Anonymous => - Anonymous - } match { - case Some(subject: Subject) => Right(Caller(subject, identities)) - case _ => - val pos = cursor.downField("identities").history - Left(DecodingFailure("Unable to find a subject in the collection of identities", pos)) - } - } - } - } - - /** - * A data structure which represents an access token - * - * @param value - * the token value - */ - final case class AccessToken(value: String) - - /** - * Base enumeration type for identity classes. - */ - sealed trait Identity extends Product with Serializable - - object Identity { - - /** - * Base enumeration type for subject classes. - */ - sealed trait Subject extends Identity - - sealed trait Anonymous extends Subject - - /** - * The Anonymous subject - */ - final case object Anonymous extends Anonymous - - /** - * The User subject - * - * @param subject - * unique user name - * @param realm - * user realm - */ - final case class User(subject: String, realm: String) extends Subject - - /** - * The Group identity - * - * @param group - * the group - * @param realm - * group realm - */ - final case class Group(group: String, realm: String) extends Identity - - /** - * The Authenticated identity - * - * @param realm - * the realm - */ - final case class Authenticated(realm: String) extends Identity - - private def decodeAnonymous(hc: HCursor): Result[Subject] = - hc.get[String]("@type").flatMap { - case "Anonymous" => Right(Anonymous) - case _ => Left(DecodingFailure("Cannot decode Anonymous Identity", hc.history)) - } - - private def decodeUser(hc: HCursor): Result[Subject] = - (hc.get[String]("subject"), hc.get[String]("realm")).mapN { case (subject, realm) => - User(subject, realm) - } - - private def decodeGroup(hc: HCursor): Result[Identity] = - (hc.get[String]("group"), hc.get[String]("realm")).mapN { case (group, realm) => - Group(group, realm) - } - - private def decodeAuthenticated(hc: HCursor): Result[Identity] = - hc.get[String]("realm").map(Authenticated) - - private val attempts = - List[HCursor => Result[Identity]](decodeAnonymous, decodeUser, decodeGroup, decodeAuthenticated) - - implicit val identityDecoder: Decoder[Identity] = - Decoder.instance { hc => - attempts.foldLeft(Left(DecodingFailure("Unexpected", hc.history)): Result[Identity]) { - case (acc @ Right(_), _) => acc - case (_, f) => f(hc) - } - } - } - -} diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClientError.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClientError.scala deleted file mode 100644 index ee2640a604..0000000000 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/DeltaIdentitiesClientError.scala +++ /dev/null @@ -1,57 +0,0 @@ -package ch.epfl.bluebrain.nexus.storage - -import akka.http.scaladsl.model.{StatusCode, StatusCodes} - -/** - * Enumeration of possible Delta Client errors. - */ - -sealed abstract class DeltaIdentitiesClientError(val msg: String) extends Exception with Product with Serializable { - override def fillInStackTrace(): DeltaIdentitiesClientError = this - override def getMessage: String = msg -} - -object DeltaIdentitiesClientError { - - final def unsafe(status: StatusCode, body: String): DeltaIdentitiesClientError = - status match { - case _ if status.isSuccess() => - throw new IllegalArgumentException(s"Successful status code '$status' found, error expected.") - case code: StatusCodes.ClientError => IdentitiesClientStatusError(code, body) - case code: StatusCodes.ServerError => IdentitiesServerStatusError(code, body) - case _ => IdentitiesUnexpectedStatusError(status, body) - } - - /** - * A serialization error when attempting to cast response. - */ - final case class IdentitiesSerializationError(message: String) - extends DeltaIdentitiesClientError( - s"a Delta request to the identities endpoint could not be converted to 'Caller' type. Details '$message'" - ) - - /** - * A Client status error (HTTP status codes 4xx). - */ - final case class IdentitiesClientStatusError(code: StatusCodes.ClientError, message: String) - extends DeltaIdentitiesClientError( - s"a Delta request to the identities endpoint that should have been successful, returned the HTTP status code '$code'. Details '$message'" - ) - - /** - * A server status error (HTTP status codes 5xx). - */ - final case class IdentitiesServerStatusError(code: StatusCodes.ServerError, message: String) - extends DeltaIdentitiesClientError( - s"a Delta request to the identities endpoint that should have been successful, returned the HTTP status code '$code'. Details '$message'" - ) - - /** - * Some other response error which is not 4xx nor 5xx - */ - final case class IdentitiesUnexpectedStatusError(code: StatusCode, message: String) - extends DeltaIdentitiesClientError( - s"a Delta request to the identities endpoint that should have been successful, returned the HTTP status code '$code'. Details '$message'" - ) - -} diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/Main.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/Main.scala index 8257a919cc..cb2e35821e 100644 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/Main.scala +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/Main.scala @@ -10,6 +10,7 @@ import akka.util.Timeout import cats.effect.Effect import ch.epfl.bluebrain.nexus.storage.Storages.DiskStorage import ch.epfl.bluebrain.nexus.storage.attributes.{AttributesCache, ContentTypeDetector} +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import ch.epfl.bluebrain.nexus.storage.config.{AppConfig, Settings} import ch.epfl.bluebrain.nexus.storage.config.AppConfig._ import ch.epfl.bluebrain.nexus.storage.routes.Routes @@ -53,13 +54,13 @@ object Main { implicit val appConfig: AppConfig = Settings(config).appConfig - implicit val as: ActorSystem = ActorSystem(appConfig.description.fullName, config) - implicit val ec: ExecutionContext = as.dispatcher - implicit val eff: Effect[Task] = Task.catsEffect(Scheduler.global) - implicit val deltaIdentities: DeltaIdentitiesClient[Task] = new DeltaIdentitiesClient[Task](appConfig.delta) - implicit val timeout = Timeout(1.minute) - implicit val clock = Clock.systemUTC - implicit val contentTypeDetector = new ContentTypeDetector(appConfig.mediaTypeDetector) + implicit val as: ActorSystem = ActorSystem(appConfig.description.fullName, config) + implicit val ec: ExecutionContext = as.dispatcher + implicit val eff: Effect[Task] = Task.catsEffect(Scheduler.global) + implicit val authorizationMethod: AuthorizationMethod = appConfig.authorization + implicit val timeout = Timeout(1.minute) + implicit val clock = Clock.systemUTC + implicit val contentTypeDetector = new ContentTypeDetector(appConfig.mediaTypeDetector) val storages: Storages[Task, AkkaSource] = new DiskStorage(appConfig.storage, contentTypeDetector, appConfig.digest, AttributesCache[Task, AkkaSource]) @@ -67,6 +68,11 @@ object Main { val logger: LoggingAdapter = Logging(as, getClass) logger.info("==== Cluster is Live ====") + + if (authorizationMethod == AuthorizationMethod.Anonymous) { + logger.warning("The application has been configured with anonymous, the caller will not be verified !") + } + val routes: Route = Routes(storages) val httpBinding: Future[Http.ServerBinding] = { diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationError.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationError.scala new file mode 100644 index 0000000000..ba5f780f08 --- /dev/null +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationError.scala @@ -0,0 +1,21 @@ +package ch.epfl.bluebrain.nexus.storage.auth + +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.TokenRejection + +sealed abstract class AuthorizationError(message: String) extends Exception with Product with Serializable { + override def fillInStackTrace(): AuthorizationError = this + override def getMessage: String = message +} + +object AuthorizationError { + + final case object NoToken extends AuthorizationError("No token has been provided.") + final case class InvalidToken(tokenRejection: TokenRejection) extends AuthorizationError(tokenRejection.getMessage) + final case class UnauthorizedUser(issuer: String, subject: String) + extends AuthorizationError( + s"User '$subject' from realm '$issuer' wrongfully attempted to perform a call to this service." + ) + final case class TokenNotVerified(tokenRejection: TokenRejection) + extends AuthorizationError(tokenRejection.getMessage) + +} diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethod.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethod.scala new file mode 100644 index 0000000000..94a58c3530 --- /dev/null +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethod.scala @@ -0,0 +1,90 @@ +package ch.epfl.bluebrain.nexus.storage.auth + +import cats.data.{NonEmptyList, NonEmptySet} +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.{AuthToken, ParsedToken} +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationError._ +import com.nimbusds.jose.jwk.{JWK, JWKSet} +import pureconfig.ConfigReader +import pureconfig.error.{CannotConvert, ConfigReaderFailures, ConvertFailure} +import pureconfig.generic.semiauto.deriveReader +import pureconfig.module.cats._ + +import scala.annotation.nowarn +import scala.jdk.CollectionConverters._ +import scala.util.Try + +/** + * Authorization config + */ +sealed trait AuthorizationMethod { + + /** + * Validates the incoming token + */ + def validate(token: Option[AuthToken]): Either[AuthorizationError, Unit] +} + +object AuthorizationMethod { + + /** + * No token/authorization is needed when performing calls + */ + final case object Anonymous extends AuthorizationMethod { + override def validate(token: Option[AuthToken]): Either[AuthorizationError, Unit] = Right(()) + } + + /** + * A token matching this realm and username is required and can be validated to the provided audiences and set of + * JSON Web Keys + */ + final case class VerifyToken(issuer: String, subject: String, audiences: Option[NonEmptySet[String]], keys: JWKSet) + extends AuthorizationMethod { + override def validate(token: Option[AuthToken]): Either[AuthorizationError, Unit] = { + for { + token <- token.toRight(NoToken) + parsedToken <- ParsedToken.fromToken(token).leftMap(InvalidToken) + _ <- Either.cond( + issuer == parsedToken.issuer && subject == parsedToken.subject, + (), + UnauthorizedUser(parsedToken.issuer, parsedToken.subject) + ) + _ <- parsedToken.validate(audiences, keys).leftMap(TokenNotVerified) + } yield () + } + } + + @nowarn("cat=unused") + implicit val authorizationMethodConfigReader: ConfigReader[AuthorizationMethod] = { + implicit val jwk: ConfigReader[JWK] = ConfigReader.fromStringTry { s => Try(JWK.parse(s)) } + implicit val jwkSet: ConfigReader[JWKSet] = ConfigReader[NonEmptyList[JWK]].map { l => new JWKSet(l.toList.asJava) } + implicit val verifyToken: ConfigReader[VerifyToken] = deriveReader[VerifyToken] + + ConfigReader.fromCursor { cursor => + for { + obj <- cursor.asObjectCursor + mc <- obj.atKey("method") + discriminator <- ConfigReader[String].from(mc) + method <- discriminator match { + case "anonymous" => Right(Anonymous) + case "verify-token" => verifyToken.from(obj) + case other => + Left( + ConfigReaderFailures( + ConvertFailure( + CannotConvert( + other, + "string", + "'method' value must be one of ('anonymous', 'verify-token')" + ), + obj + ) + ) + ) + } + } yield method + } + + } + +} diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/AppConfig.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/AppConfig.scala index 4c20aa4abe..130c3d402f 100644 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/AppConfig.scala +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/AppConfig.scala @@ -1,12 +1,12 @@ package ch.epfl.bluebrain.nexus.storage.config -import java.nio.file.Path import akka.http.scaladsl.model.Uri import ch.epfl.bluebrain.nexus.delta.kernel.http.MediaTypeDetectorConfig -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Identity.{Anonymous, Subject, User} import ch.epfl.bluebrain.nexus.storage.JsonLdCirceSupport.OrderedKeys +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import ch.epfl.bluebrain.nexus.storage.config.AppConfig._ +import java.nio.file.Path import scala.concurrent.duration.FiniteDuration /** @@ -18,10 +18,10 @@ import scala.concurrent.duration.FiniteDuration * http interface configuration * @param storage * storages configuration - * @param subject - * allowed subject to perform calls to this service - * @param delta - * delta client configuration + * @param authorization + * authorization configuration + * @param mediaTypeDetector + * media type configuration * @param digest * the digest configuration */ @@ -29,8 +29,7 @@ final case class AppConfig( description: Description, http: HttpConfig, storage: StorageConfig, - subject: SubjectConfig, - delta: DeltaClientConfig, + authorization: AuthorizationMethod, mediaTypeDetector: MediaTypeDetectorConfig, digest: DigestConfig ) @@ -93,33 +92,6 @@ object AppConfig { fixerCommand: Vector[String] ) - /** - * Allowed subject to perform calls to this service - * - * @param anonymous - * flag to decide whether or not the allowed subject is Anonymous or a User - * @param realm - * the user realm. It must be present when anonymous = false and it must be removed when anonymous = true - * @param name - * the user name. It must be present when anonymous = false and it must be removed when anonymous = true - */ - final case class SubjectConfig(anonymous: Boolean, realm: Option[String], name: Option[String]) { - // $COVERAGE-OFF$ - val subjectValue: Subject = (anonymous, realm, name) match { - case (false, Some(r), Some(s)) => User(s, r) - case (false, _, _) => - throw new IllegalArgumentException( - "subject configuration is wrong. When anonymous is set to false, a realm and a subject must be provided" - ) - case (true, None, None) => Anonymous - case _ => - throw new IllegalArgumentException( - "subject configuration is wrong. When anonymous is set to true, a realm and a subject should not be present" - ) - } - // $COVERAGE-ON$ - } - /** * The digest configuration. * @@ -142,10 +114,9 @@ object AppConfig { retriggerAfter: FiniteDuration ) - implicit def toStorage(implicit config: AppConfig): StorageConfig = config.storage - implicit def toHttp(implicit config: AppConfig): HttpConfig = config.http - implicit def toDelta(implicit config: AppConfig): DeltaClientConfig = config.delta - implicit def toDigest(implicit config: AppConfig): DigestConfig = config.digest + implicit def toStorage(implicit config: AppConfig): StorageConfig = config.storage + implicit def toHttp(implicit config: AppConfig): HttpConfig = config.http + implicit def toDigest(implicit config: AppConfig): DigestConfig = config.digest val orderedKeys: OrderedKeys = OrderedKeys( List( diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/DeltaClientConfig.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/DeltaClientConfig.scala deleted file mode 100644 index ea98fe5d61..0000000000 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/config/DeltaClientConfig.scala +++ /dev/null @@ -1,24 +0,0 @@ -package ch.epfl.bluebrain.nexus.storage.config - -import akka.http.scaladsl.model.Uri -import ch.epfl.bluebrain.nexus.storage.UriUtils.addPath - -/** - * Configuration for DeltaClient identities endpoint. - * - * @param publicIri - * base URL for all the identity IDs, excluding prefix. - * @param internalIri - * base URL for all the HTTP calls, excluding prefix. - * @param prefix - * the prefix - */ -final case class DeltaClientConfig( - publicIri: Uri, - internalIri: Uri, - prefix: String -) { - lazy val baseInternalIri: Uri = addPath(internalIri, prefix) - lazy val basePublicIri: Uri = addPath(publicIri, prefix) - lazy val identitiesIri: Uri = addPath(baseInternalIri, "identities") -} diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectives.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectives.scala index f67b6d034c..ef8cebffd6 100644 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectives.scala +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectives.scala @@ -1,19 +1,12 @@ package ch.epfl.bluebrain.nexus.storage.routes -import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.headers.OAuth2BearerToken -import akka.http.scaladsl.server.Directive1 +import akka.http.scaladsl.server.Directive0 import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.directives.FutureDirectives.onComplete -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.{AccessToken, Caller} -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClientError.IdentitiesClientStatusError +import ch.epfl.bluebrain.nexus.delta.kernel.jwt.AuthToken import ch.epfl.bluebrain.nexus.storage.StorageError._ +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import com.typesafe.scalalogging.Logger -import monix.eval.Task -import monix.execution.Scheduler.Implicits.global - -import scala.util.{Failure, Success} object AuthDirectives { @@ -22,24 +15,19 @@ object AuthDirectives { /** * Extracts the credentials from the HTTP Authorization Header and builds the [[AccessToken]] */ - def extractToken: Directive1[Option[AccessToken]] = + def validateUser(implicit authorizationMethod: AuthorizationMethod): Directive0 = { + def validate(token: Option[AuthToken]): Directive0 = + authorizationMethod.validate(token) match { + case Left(error) => + logger.error("The user could not be validated.", error) + failWith(AuthenticationFailed) + case Right(_) => pass + } + extractCredentials.flatMap { - case Some(OAuth2BearerToken(value)) => provide(Some(AccessToken(value))) + case Some(OAuth2BearerToken(value)) => validate(Some(AuthToken(value))) case Some(_) => failWith(AuthenticationFailed) - case _ => provide(None) - } - - /** - * Authenticates the requested with the provided ''token'' and returns the ''caller'' - */ - def extractCaller(implicit identities: DeltaIdentitiesClient[Task], token: Option[AccessToken]): Directive1[Caller] = - onComplete(identities().runToFuture).flatMap { - case Success(caller) => provide(caller) - case Failure(IdentitiesClientStatusError(StatusCodes.Unauthorized, _)) => failWith(AuthenticationFailed) - case Failure(IdentitiesClientStatusError(StatusCodes.Forbidden, _)) => failWith(AuthorizationFailed) - case Failure(err) => - val message = "Error when trying to extract the subject" - logger.error(message, err) - failWith(InternalError(message)) + case _ => validate(None) } + } } diff --git a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/Routes.scala b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/Routes.scala index 54ec884323..5f64fb99c1 100644 --- a/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/Routes.scala +++ b/storage/src/main/scala/ch/epfl/bluebrain/nexus/storage/routes/Routes.scala @@ -3,14 +3,14 @@ package ch.epfl.bluebrain.nexus.storage.routes import akka.http.scaladsl.model.headers.{`WWW-Authenticate`, HttpChallenges} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler, Route} -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Caller import ch.epfl.bluebrain.nexus.storage.StorageError._ +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import ch.epfl.bluebrain.nexus.storage.config.AppConfig import ch.epfl.bluebrain.nexus.storage.config.AppConfig._ import ch.epfl.bluebrain.nexus.storage.routes.AuthDirectives._ import ch.epfl.bluebrain.nexus.storage.routes.PrefixDirectives._ import ch.epfl.bluebrain.nexus.storage.routes.instances._ -import ch.epfl.bluebrain.nexus.storage.{AkkaSource, DeltaIdentitiesClient, Rejection, StorageError, Storages} +import ch.epfl.bluebrain.nexus.storage.{AkkaSource, Rejection, StorageError, Storages} import com.typesafe.scalalogging.Logger import monix.eval.Task @@ -85,16 +85,13 @@ object Routes { */ def apply( storages: Storages[Task, AkkaSource] - )(implicit config: AppConfig, identities: DeltaIdentitiesClient[Task]): Route = + )(implicit config: AppConfig, authorizationMethod: AuthorizationMethod): Route = //TODO: Fetch Bearer token and verify identity wrap { concat( AppInfoRoutes(config.description).routes, - (pathPrefix(config.http.prefix) & extractToken) { implicit token => - extractCaller.apply { - case Caller(config.subject.subjectValue, _) => StorageRoutes(storages).routes - case _ => failWith(AuthenticationFailed) - } + (pathPrefix(config.http.prefix) & validateUser) { + StorageRoutes(storages).routes } ) } diff --git a/storage/src/test/resources/app.conf b/storage/src/test/resources/app.conf index a9df4fd6fd..3899e80d82 100644 --- a/storage/src/test/resources/app.conf +++ b/storage/src/test/resources/app.conf @@ -1,8 +1,7 @@ # All application specific configuration should reside here app { - # Allowed subject to perform calls - subject { - # flag to decide whether or not the allowed subject is Anonymous or a User - anonymous = true + # Authorization method + authorization { + method = anonymous } } \ No newline at end of file diff --git a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethodSuite.scala b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethodSuite.scala new file mode 100644 index 0000000000..6abf67af1b --- /dev/null +++ b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/auth/AuthorizationMethodSuite.scala @@ -0,0 +1,148 @@ +package ch.epfl.bluebrain.nexus.storage.auth + +import ch.epfl.bluebrain.nexus.storage.utils.Randomness.genString +import cats.data.NonEmptySet +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod._ +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jose.jwk.{JWK, JWKSet, RSAKey} +import munit.FunSuite +import pureconfig.ConfigSource + +import scala.jdk.CollectionConverters._ + +class AuthorizationMethodSuite extends FunSuite { + + private def generateKey: RSAKey = new RSAKeyGenerator(2048).keyID(genString()).generate() + + private def parseConfig(value: String) = + ConfigSource.string(value).at("authorization").load[AuthorizationMethod] + + test("Parse successfully for the anonymous method") { + val config = parseConfig( + """ + |authorization { + | method = anonymous + |} + |""".stripMargin + ) + assertEquals(config, Right(Anonymous)) + } + + test("Parse successfully for the verify token method") { + val key1: JWK = generateKey.toPublicJWK + val key2: JWK = generateKey.toPublicJWK + + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | issuer = bbp + | subject = admin + | audiences = [dev, staging] + | keys = [ "${key1.toJSONString}", "${key2.toJSONString}"] + |} + |""".stripMargin + ) + + val expectedAudiences = Some(NonEmptySet.of("dev", "staging")) + val expectedKeySet = new JWKSet(List(key1, key2).asJava) + val expected = VerifyToken("bbp", "admin", expectedAudiences, expectedKeySet) + + assertEquals(config, Right(expected)) + } + + test("Parse successfully without audiences") { + val key1: JWK = generateKey.toPublicJWK + + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | issuer = bbp + | subject = admin + | keys = [ "${key1.toJSONString}" ] + |} + |""".stripMargin + ) + + val expectedAudiences = None + val expectedKeySet = new JWKSet(key1) + val expected = VerifyToken("bbp", "admin", expectedAudiences, expectedKeySet) + + assertEquals(config, Right(expected)) + } + + test("Fail to parse the config if the issuer is missing") { + val key1: JWK = generateKey.toPublicJWK + + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | subject = admin + | keys = [ "${key1.toJSONString}" ] + |} + |""".stripMargin + ) + + assert(config.isLeft, "Parsing must fail with an missing issuer") + } + + test("Fail to parse the config if the subject is missing") { + val key1: JWK = generateKey.toPublicJWK + + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | issuer = bbp + | keys = [ "${key1.toJSONString}" ] + |} + |""".stripMargin + ) + + assert(config.isLeft, "Parsing must fail with an missing subject") + } + + test("Fail to parse the config if the key is invalid") { + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | issuer = bbp + | subject = admin + | keys = [ "xxx" ] + |} + |""".stripMargin + ) + + assert(config.isLeft, "Parsing must fail with an invalid key") + } + + test("Fail to parse the config without a key") { + val config = parseConfig( + s""" + |authorization { + | method = verify-token + | issuer = bbp + | subject = admin + | keys = [ ] + |} + |""".stripMargin + ) + + assert(config.isLeft, "Parsing must fail without a key") + } + + test("Fail to parse the config with an invalid method") { + val config = parseConfig( + """ + |authorization { + | method = xxx + |} + |""".stripMargin + ) + assert(config.isLeft, "Parsing must fail with an invalid method") + } + +} diff --git a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AppInfoRoutesSpec.scala b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AppInfoRoutesSpec.scala index 97157d5608..828fdbb5d9 100644 --- a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AppInfoRoutesSpec.scala +++ b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AppInfoRoutesSpec.scala @@ -1,20 +1,21 @@ package ch.epfl.bluebrain.nexus.storage.routes -import java.util.regex.Pattern.quote - import akka.http.scaladsl.model.StatusCodes._ import akka.http.scaladsl.server.Route import akka.http.scaladsl.testkit.ScalatestRouteTest +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import ch.epfl.bluebrain.nexus.storage.config.{AppConfig, Settings} import ch.epfl.bluebrain.nexus.storage.routes.instances._ import ch.epfl.bluebrain.nexus.storage.utils.Resources -import ch.epfl.bluebrain.nexus.storage.{AkkaSource, DeltaIdentitiesClient, Storages} +import ch.epfl.bluebrain.nexus.storage.{AkkaSource, Storages} import io.circe.Json import monix.eval.Task import org.mockito.IdiomaticMockito import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike +import java.util.regex.Pattern.quote + class AppInfoRoutesSpec extends AnyWordSpecLike with Matchers @@ -24,9 +25,9 @@ class AppInfoRoutesSpec "the app info routes" should { - implicit val config: AppConfig = Settings(system).appConfig - implicit val deltaIdentities: DeltaIdentitiesClient[Task] = mock[DeltaIdentitiesClient[Task]] - val route: Route = Routes(mock[Storages[Task, AkkaSource]]) + implicit val config: AppConfig = Settings(system).appConfig + implicit val authorizationMethod: AuthorizationMethod = AuthorizationMethod.Anonymous + val route: Route = Routes(mock[Storages[Task, AkkaSource]]) "return application information" in { Get("/") ~> route ~> check { diff --git a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectivesSpec.scala b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectivesSpec.scala index 294e721e91..cbef60869c 100644 --- a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectivesSpec.scala +++ b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/AuthDirectivesSpec.scala @@ -4,98 +4,111 @@ import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.headers.OAuth2BearerToken import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.testkit.ScalatestRouteTest -import ch.epfl.bluebrain.nexus.storage.{DeltaIdentitiesClient, DeltaIdentitiesClientError} -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Identity.Anonymous -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.{AccessToken, Caller} +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod.VerifyToken import ch.epfl.bluebrain.nexus.storage.config.AppConfig.HttpConfig import ch.epfl.bluebrain.nexus.storage.config.Settings import ch.epfl.bluebrain.nexus.storage.routes.AuthDirectives._ import ch.epfl.bluebrain.nexus.storage.utils.EitherValues -import monix.eval.Task -import org.mockito.matchers.MacroBasedMatchers -import org.mockito.{IdiomaticMockito, Mockito} +import ch.epfl.bluebrain.nexus.storage.utils.Randomness.genString +import ch.epfl.bluebrain.nexus.testkit.jwt.TokenGenerator +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jose.jwk.{JWKSet, RSAKey} import org.scalatest.BeforeAndAfter import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike +import java.time.Instant + //noinspection NameBooleanParameters class AuthDirectivesSpec extends AnyWordSpecLike with Matchers with EitherValues - with MacroBasedMatchers - with IdiomaticMockito with BeforeAndAfter with ScalatestRouteTest { implicit private val hc: HttpConfig = Settings(system).appConfig.http - implicit private val deltaIdentities: DeltaIdentitiesClient[Task] = mock[DeltaIdentitiesClient[Task]] - - before { - Mockito.reset(deltaIdentities) - } + def validateRoute(implicit authorizationMethod: AuthorizationMethod) = Routes.wrap(validateUser.apply { + complete("") + }) - "The AuthDirectives" should { + "Validating with the anonymous method" should { - "extract the token" in { + implicit val anonymousMethod: AuthorizationMethod = AuthorizationMethod.Anonymous + "validate any token" in { val expected = "token" - val route = extractToken { - case Some(AccessToken(`expected`)) => complete("") - case Some(_) => fail("Token was not extracted correctly.") - case None => fail("Token was not extracted.") - } - Get("/").addCredentials(OAuth2BearerToken(expected)) ~> route ~> check { + Get("/").addCredentials(OAuth2BearerToken(expected)) ~> validateRoute ~> check { status shouldEqual StatusCodes.OK } } - "extract no token" in { - val route = extractToken { - case None => complete("") - case t @ Some(_) => fail(s"Extracted unknown token '$t'.") - } - Get("/") ~> route ~> check { + "validate if no token is provided" in { + Get("/") ~> validateRoute ~> check { status shouldEqual StatusCodes.OK } } + } + + "Validating with the verify token method" should { + + def generateKey: RSAKey = new RSAKeyGenerator(2048).keyID(genString()).generate() + + val rsaKey = generateKey + val validIssuer = "bbp" + val validSubject = "admin" - "extract the caller" in { - implicit val token: Option[AccessToken] = None - deltaIdentities()(any[Option[AccessToken]]) shouldReturn Task(Caller(Anonymous, Set.empty)) - val route = Routes.wrap(extractCaller.apply(_ => complete(""))) - Get("/") ~> route ~> check { + def generateToken(subject: String, issuer: String, rsaKey: RSAKey) = + TokenGenerator + .generateToken( + subject, + issuer, + rsaKey, + Instant.now().plusSeconds(100L), + Instant.now().minusSeconds(100L), + None, + None, + false, + Some(subject) + ) + .value + + implicit val anonymousMethod: AuthorizationMethod = + VerifyToken(validIssuer, validSubject, None, new JWKSet(rsaKey.toPublicJWK)) + + "Succeed with a valid token" in { + val token = generateToken(validSubject, validIssuer, rsaKey) + Get("/").addCredentials(OAuth2BearerToken(token)) ~> validateRoute ~> check { status shouldEqual StatusCodes.OK } } - "fail the route" when { + "Fail with an invalid issuer" in { + val token = generateToken(validSubject, "xxx", rsaKey) + Get("/").addCredentials(OAuth2BearerToken(token)) ~> validateRoute ~> check { + status shouldEqual StatusCodes.Unauthorized + } + } - "the client throws an error for caller" in { - implicit val token: Option[AccessToken] = None - deltaIdentities()(any[Option[AccessToken]]) shouldReturn - Task.raiseError(DeltaIdentitiesClientError.IdentitiesServerStatusError(StatusCodes.InternalServerError, "")) - val route = Routes.wrap(extractCaller.apply(_ => complete(""))) - Get("/") ~> route ~> check { - status shouldEqual StatusCodes.InternalServerError - } + "Fail with an invalid subject" in { + val token = generateToken("bob", validIssuer, rsaKey) + Get("/").addCredentials(OAuth2BearerToken(token)) ~> validateRoute ~> check { + status shouldEqual StatusCodes.Unauthorized } - "the client returns Unauthorized for caller" in { - implicit val token: Option[AccessToken] = None - deltaIdentities()(any[Option[AccessToken]]) shouldReturn - Task.raiseError(DeltaIdentitiesClientError.IdentitiesClientStatusError(StatusCodes.Unauthorized, "")) - val route = Routes.wrap(extractCaller.apply(_ => complete(""))) - Get("/") ~> route ~> check { - status shouldEqual StatusCodes.Unauthorized - } + } + + "Fail with a token signed with another key" in { + val anotherKey: RSAKey = new RSAKeyGenerator(2048).keyID(genString()).generate() + val token = generateToken(validSubject, validIssuer, anotherKey) + Get("/").addCredentials(OAuth2BearerToken(token)) ~> validateRoute ~> check { + status shouldEqual StatusCodes.Unauthorized } - "the client returns Forbidden for caller" in { - implicit val token: Option[AccessToken] = None - deltaIdentities()(any[Option[AccessToken]]) shouldReturn - Task.raiseError(DeltaIdentitiesClientError.IdentitiesClientStatusError(StatusCodes.Forbidden, "")) - val route = Routes.wrap(extractCaller.apply(_ => complete(""))) - Get("/") ~> route ~> check { - status shouldEqual StatusCodes.Forbidden - } + } + + "Fail with an invalid token" in { + val token = "token" + Get("/").addCredentials(OAuth2BearerToken(token)) ~> validateRoute ~> check { + status shouldEqual StatusCodes.Unauthorized } } } diff --git a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/StorageRoutesSpec.scala b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/StorageRoutesSpec.scala index 3271f4289b..749133e89b 100644 --- a/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/StorageRoutesSpec.scala +++ b/storage/src/test/scala/ch/epfl/bluebrain/nexus/storage/routes/StorageRoutesSpec.scala @@ -12,18 +12,17 @@ import akka.http.scaladsl.server.Route import akka.http.scaladsl.testkit.ScalatestRouteTest import akka.stream.scaladsl.Source import akka.util.ByteString -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Caller -import ch.epfl.bluebrain.nexus.storage.DeltaIdentitiesClient.Identity.Anonymous import ch.epfl.bluebrain.nexus.storage.File.{Digest, FileAttributes} import ch.epfl.bluebrain.nexus.storage.Rejection.PathNotFound import ch.epfl.bluebrain.nexus.storage.StorageError.InternalError import ch.epfl.bluebrain.nexus.storage.Storages.BucketExistence.{BucketDoesNotExist, BucketExists} import ch.epfl.bluebrain.nexus.storage.Storages.PathExistence.{PathDoesNotExist, PathExists} +import ch.epfl.bluebrain.nexus.storage.auth.AuthorizationMethod import ch.epfl.bluebrain.nexus.storage.config.{AppConfig, Settings} import ch.epfl.bluebrain.nexus.storage.jsonld.JsonLdContext.addContext import ch.epfl.bluebrain.nexus.storage.routes.instances._ import ch.epfl.bluebrain.nexus.storage.utils.{Randomness, Resources} -import ch.epfl.bluebrain.nexus.storage.{AkkaSource, DeltaIdentitiesClient, Storages} +import ch.epfl.bluebrain.nexus.storage.{AkkaSource, Storages} import io.circe.Json import monix.eval.Task import org.mockito.{ArgumentMatchersSugar, IdiomaticMockito} @@ -49,12 +48,10 @@ class StorageRoutesSpec implicit override def patienceConfig: PatienceConfig = PatienceConfig(3.second, 15.milliseconds) - implicit val appConfig: AppConfig = Settings(system).appConfig - implicit val deltaIdentities: DeltaIdentitiesClient[Task] = mock[DeltaIdentitiesClient[Task]] - val storages: Storages[Task, AkkaSource] = mock[Storages[Task, AkkaSource]] - val route: Route = Routes(storages) - - deltaIdentities()(None) shouldReturn Task(Caller(Anonymous, Set.empty)) + implicit val appConfig: AppConfig = Settings(system).appConfig + implicit val authorizationMethod: AuthorizationMethod = AuthorizationMethod.Anonymous + val storages: Storages[Task, AkkaSource] = mock[Storages[Task, AkkaSource]] + val route: Route = Routes(storages) trait Ctx { val name = genString() diff --git a/tests/docker/config/storage.conf b/tests/docker/config/storage.conf index a4300e6137..67f187162d 100644 --- a/tests/docker/config/storage.conf +++ b/tests/docker/config/storage.conf @@ -8,21 +8,14 @@ app { interface = "0.0.0.0" } - subject { - anonymous = false - realm = "internal" - name = "service-account-delta" - } - storage { root-volume = "/tmp" protected-directory = "protected" fixer-enabled = false } - delta { - public-iri = "https://test.nexus.bbp.epfl.ch" - internal-iri = "http://delta:8080" + authorization { + method = "anonymous" } media-type-detector {