diff --git a/build.sbt b/build.sbt index d8b80036..df38ab19 100644 --- a/build.sbt +++ b/build.sbt @@ -160,8 +160,8 @@ lazy val `mauth-authenticator-akka-http` = scalaModuleProject("mauth-authenticat .settings( publishSettings, libraryDependencies ++= - Dependencies.provided(akkaHttp, akkaStream) ++ - Dependencies.compile(jacksonDataBind, scalaCacheCaffeine).map(withExclusions) ++ + Dependencies.provided(akkaHttp, akkaStream, akkaHttpCache) ++ + Dependencies.compile(jacksonDataBind).map(withExclusions) ++ Dependencies.test(scalaTest, scalaMock, wiremock) ++ Dependencies.test(akkaHttpTestKit *).map(withExclusions) ) diff --git a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala index f4e97449..25c9d9d3 100644 --- a/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala +++ b/modules/mauth-authenticator-akka-http/src/main/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProvider.scala @@ -1,13 +1,13 @@ package com.mdsol.mauth.akka.http import akka.actor.ActorSystem +import akka.http.caching.scaladsl.CachingSettings +import akka.http.caching.LfuCache import akka.http.scaladsl.model.{HttpResponse, StatusCodes} import akka.http.scaladsl.unmarshalling.Unmarshal import akka.stream.Materializer import cats.effect.IO -import cats.effect.unsafe.implicits.global import com.fasterxml.jackson.databind.ObjectMapper -import com.github.benmanes.caffeine.cache.Caffeine import com.mdsol.mauth.http.HttpClient import com.mdsol.mauth.http.Implicits._ import com.mdsol.mauth.models.UnsignedRequest @@ -15,14 +15,11 @@ import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider import com.mdsol.mauth.util.MAuthKeysHelper import com.mdsol.mauth.{AuthenticatorConfiguration, MAuthRequestSigner} import com.typesafe.scalalogging.StrictLogging -import scalacache.caffeine.CaffeineCache -import scalacache.memoization._ -import scalacache.{Cache, Entry} import java.net.URI import java.security.PublicKey import java.util.UUID -import scala.concurrent.duration._ +import scala.concurrent.duration.DurationLong import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} @@ -33,49 +30,54 @@ class MauthPublicKeyProvider(configuration: AuthenticatorConfiguration, signer: ) extends ClientPublicKeyProvider[Future] with StrictLogging { - private val cCache = Caffeine.newBuilder().build[String, Entry[Option[PublicKey]]]() - implicit val caffeineCache: Cache[IO, String, Option[PublicKey]] = CaffeineCache[IO, String, Option[PublicKey]](underlying = cCache) protected val mapper = new ObjectMapper + private val defaultCachingSettings = CachingSettings(system) + private val lfuCacheSettings = defaultCachingSettings.lfuCacheSettings.withTimeToLive(configuration.getTimeToLive.seconds) + private val cache = LfuCache.apply[UUID, Option[PublicKey]](defaultCachingSettings.withLfuCacheSettings(lfuCacheSettings)) + /** Returns the associated public key for a given application UUID. * * @param appUUID , UUID of the application for which we want to retrieve its public key. * @return { @link PublicKey} registered in MAuth for the application with given appUUID. */ override def getPublicKey(appUUID: UUID): Future[Option[PublicKey]] = - getPublicKeyIO(appUUID).unsafeToFuture() + cache.getOrLoad( + appUUID, + _ => { + val signedRequest = + signer.signRequest(UnsignedRequest.noBody("GET", new URI(configuration.getBaseUrl + getRequestUrlPath(appUUID)), headers = Map.empty)) + retrievePublicKey()(HttpClient.call(signedRequest.toAkkaHttpRequest)) - def getPublicKeyIO(appUUID: UUID): IO[Option[PublicKey]] = memoizeF(Some(configuration.getTimeToLive.seconds)) { - val signedRequest = signer.signRequest(UnsignedRequest.noBody("GET", new URI(configuration.getBaseUrl + getRequestUrlPath(appUUID)), headers = Map.empty)) - retrievePublicKey()(IO.fromFuture(IO(HttpClient.call(signedRequest.toAkkaHttpRequest)))) - } + } + ) - protected def retrievePublicKey()(mauthPublicKeyFetcher: => IO[HttpResponse]): IO[Option[PublicKey]] = { + def getPublicKeyIO(appUUID: UUID): IO[Option[PublicKey]] = IO.fromFuture(IO(getPublicKey(appUUID))) + + protected def retrievePublicKey()(mauthPublicKeyFetcher: => Future[HttpResponse]): Future[Option[PublicKey]] = { mauthPublicKeyFetcher .flatMap { response => - IO.fromFuture( - IO( - Unmarshal(response.entity) - .to[String] - ) - ).map { body => - if (response.status == StatusCodes.OK) { - Try(MAuthKeysHelper.getPublicKeyFromString(mapper.readTree(body).findValue("public_key_str").asText)) match { - case Success(publicKey) => Some(publicKey) - case Failure(error) => - logger.error("Converting string to Public Key failed", error) - None + Unmarshal(response.entity) + .to[String] + .map { body => + if (response.status == StatusCodes.OK) { + Try(MAuthKeysHelper.getPublicKeyFromString(mapper.readTree(body).findValue("public_key_str").asText)) match { + case Success(publicKey) => Some(publicKey) + case Failure(error) => + logger.error("Converting string to Public Key failed", error) + None + } + } else { + logger.error(s"Unexpected response returned by server -- status: ${response.status} response: $body") + None } - } else { - logger.error(s"Unexpected response returned by server -- status: ${response.status} response: $body") + } + .recover[Option[PublicKey]] { case error: Throwable => + logger.error("Request to get MAuth public key couldn't be signed", error) None } - }.handleError { error: Throwable => - logger.error("Request to get MAuth public key couldn't be signed", error) - None - } } - .handleError { error: Throwable => + .recover[Option[PublicKey]] { case error: Throwable => logger.error("Request to get MAuth public key couldn't be completed", error) None } diff --git a/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProviderSpec.scala b/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProviderSpec.scala index dad3013f..7fb0915d 100644 --- a/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProviderSpec.scala +++ b/modules/mauth-authenticator-akka-http/src/test/scala/com/mdsol/mauth/akka/http/MauthPublicKeyProviderSpec.scala @@ -3,20 +3,20 @@ package com.mdsol.mauth.akka.http import java.net.URI import java.security.Security import akka.actor.ActorSystem +import cats.effect.unsafe.IORuntime import com.mdsol.mauth.models.{SignedRequest, UnsignedRequest} import com.mdsol.mauth.test.utils.{FakeMAuthServer, PortFinder} import com.mdsol.mauth.{AuthenticatorConfiguration, MAuthRequest, MAuthRequestSigner} import org.bouncycastle.jce.provider.BouncyCastleProvider import org.scalamock.scalatest.MockFactory import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} -import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} - -import scala.concurrent.ExecutionContext.Implicits.global +import cats.implicits._ +import org.scalatest.wordspec.AnyWordSpec class MauthPublicKeyProviderSpec - extends AnyFlatSpec + extends AnyWordSpec with BeforeAndAfterAll with BeforeAndAfterEach with ScalaFutures @@ -24,6 +24,9 @@ class MauthPublicKeyProviderSpec with Matchers with MockFactory { + implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global + implicit val ioRuntime: IORuntime = cats.effect.unsafe.implicits.global + implicit val system: ActorSystem = ActorSystem() private val EXPECTED_TIME_HEADER_VALUE = "1444672125" private val EXPECTED_AUTHENTICATION_HEADER_VALUE = "MWS 92a1869e-c80d-4f06-8775-6c4ebb0758e0:lTMYNWPaG4..." @@ -53,48 +56,82 @@ class MauthPublicKeyProviderSpec private def getMAuthConfiguration = new AuthenticatorConfiguration(MAUTH_BASE_URL, MAUTH_URL_PATH, SECURITY_TOKENS_PATH) - "MauthPublicKeyProvider" should "retrieve PublicKey from MAuth Server" in { - FakeMAuthServer.return200() - val mockedSigner = mock[MAuthRequestSigner] - val unsignedRequest = UnsignedRequest( - "GET", - URI.create(MAUTH_BASE_URL + getRequestUrlPath(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString)), - body = Array.empty, - headers = Map.empty - ) - val mockedResponse = SignedRequest( - unsignedRequest, - mauthHeaders = Map( - "not_testing_signer_behaviour" -> "So any header is ok" + "MauthPublicKeyProvider" should { + "retrieve PublicKey from MAuth Server" in { + FakeMAuthServer.return200() + val mockedSigner = mock[MAuthRequestSigner] + val unsignedRequest = UnsignedRequest( + "GET", + URI.create(MAUTH_BASE_URL + getRequestUrlPath(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString)), + body = Array.empty, + headers = Map.empty + ) + val mockedResponse = SignedRequest( + unsignedRequest, + mauthHeaders = Map( + "not_testing_signer_behaviour" -> "So any header is ok" + ) ) - ) - (mockedSigner.signRequest(_: UnsignedRequest)).expects(*).returns(mockedResponse) + (mockedSigner.signRequest(_: UnsignedRequest)).expects(*).returns(mockedResponse) - whenReady(new MauthPublicKeyProvider(getMAuthConfiguration, mockedSigner).getPublicKey(FakeMAuthServer.EXISTING_CLIENT_APP_UUID)) { result => - result.toString should not be empty + whenReady(new MauthPublicKeyProvider(getMAuthConfiguration, mockedSigner).getPublicKey(FakeMAuthServer.EXISTING_CLIENT_APP_UUID)) { result => + result.toString should not be empty + } } - } + "only make one call to mAuth when multiple calls happen in parallel and the cache is empty" in { + FakeMAuthServer.return200() + val mockedSigner = mock[MAuthRequestSigner] + val unsignedRequest = UnsignedRequest( + "GET", + URI.create(MAUTH_BASE_URL + getRequestUrlPath(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString)), + body = Array.empty, + headers = Map.empty + ) + val mockedResponse = SignedRequest( + unsignedRequest, + mauthHeaders = Map( + "not_testing_signer_behaviour" -> "So any header is ok" + ) + ) + (mockedSigner.signRequest(_: UnsignedRequest)).expects(*).returns(mockedResponse).anyNumberOfTimes() //No? - it should "fail on invalid response from MAuth Server" in { - FakeMAuthServer.return401() - val mockedSigner = mock[MAuthRequestSigner] - val unsignedRequest = UnsignedRequest( - "GET", - URI.create(MAUTH_BASE_URL + getRequestUrlPath(FakeMAuthServer.NON_EXISTING_CLIENT_APP_UUID.toString)), - body = Array.empty, - headers = Map.empty - ) - val mockedResponse = SignedRequest( - unsignedRequest, - mauthHeaders = Map( - "not_testing_signer_behaviour" -> "So any header is ok" + val provider = new MauthPublicKeyProvider(getMAuthConfiguration, mockedSigner) + FakeMAuthServer.verifyNumberOfRequests(1) + + whenReady( + List + .fill(100)(provider.getPublicKey(FakeMAuthServer.EXISTING_CLIENT_APP_UUID)) + .sequence + ) { + _.map { result => + result.toString should not be empty + } + } + + FakeMAuthServer.verifyNumberOfRequests(2) + + } + "fail on invalid response from MAuth Server" in { + FakeMAuthServer.return401() + val mockedSigner = mock[MAuthRequestSigner] + val unsignedRequest = UnsignedRequest( + "GET", + URI.create(MAUTH_BASE_URL + getRequestUrlPath(FakeMAuthServer.NON_EXISTING_CLIENT_APP_UUID.toString)), + body = Array.empty, + headers = Map.empty + ) + val mockedResponse = SignedRequest( + unsignedRequest, + mauthHeaders = Map( + "not_testing_signer_behaviour" -> "So any header is ok" + ) ) - ) - (mockedSigner.signRequest(_: UnsignedRequest)).expects(*).returns(mockedResponse) + (mockedSigner.signRequest(_: UnsignedRequest)).expects(*).returns(mockedResponse) - whenReady(new MauthPublicKeyProvider(getMAuthConfiguration, mockedSigner).getPublicKey(FakeMAuthServer.EXISTING_CLIENT_APP_UUID)) { - case Some(_) => fail("returned a public key, expected None") - case None => + whenReady(new MauthPublicKeyProvider(getMAuthConfiguration, mockedSigner).getPublicKey(FakeMAuthServer.EXISTING_CLIENT_APP_UUID)) { + case Some(_) => fail("returned a public key, expected None") + case None => + } } } diff --git a/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala index f4ded736..7efc6674 100644 --- a/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala +++ b/modules/mauth-authenticator-http4s/src/main/scala/com/mdsol/mauth/http4s/MauthPublicKeyProvider.scala @@ -1,7 +1,7 @@ package com.mdsol.mauth.http4s import cats.ApplicativeThrow -import cats.effect.{Concurrent, Sync} +import cats.effect.{Async, Outcome, Sync} import com.mdsol.mauth.http4s.client.Implicits.NewSignedRequestOps import com.mdsol.mauth.models.UnsignedRequest import com.mdsol.mauth.scaladsl.utils.ClientPublicKeyProvider @@ -9,7 +9,6 @@ import com.mdsol.mauth.util.MAuthKeysHelper import com.mdsol.mauth.{AuthenticatorConfiguration, MAuthRequestSigner} import org.http4s.client.Client import org.http4s.{Response, Status} -import scalacache.memoization.memoizeF import scalacache.{Cache, Entry} import scalacache.caffeine.CaffeineCache @@ -23,9 +22,10 @@ import com.mdsol.mauth.http4s.MauthPublicKeyProvider.SecurityToken import io.circe.{Decoder, HCursor} import org.http4s.circe.CirceEntityDecoder._ import org.typelevel.log4cats.Logger +import cats.effect.implicits._ -class MauthPublicKeyProvider[F[_]: Concurrent: Logger](configuration: AuthenticatorConfiguration, signer: MAuthRequestSigner, val client: Client[F])(implicit - val cache: Cache[F, String, Option[PublicKey]] +class MauthPublicKeyProvider[F[_]: Async: Logger](configuration: AuthenticatorConfiguration, signer: MAuthRequestSigner, val client: Client[F])(implicit + val cache: Cache[F, UUID, F[Option[PublicKey]]] ) extends ClientPublicKeyProvider[F] { /** Returns the associated public key for a given application UUID. @@ -33,13 +33,28 @@ class MauthPublicKeyProvider[F[_]: Concurrent: Logger](configuration: Authentica * @param appUUID , UUID of the application for which we want to retrieve its public key. * @return { @link PublicKey} registered in MAuth for the application with given appUUID. */ - override def getPublicKey(appUUID: UUID): F[Option[PublicKey]] = memoizeF(Some(configuration.getTimeToLive.seconds)) { - val uri = new URI(configuration.getBaseUrl + getRequestUrlPath(appUUID)) - val signedRequest = signer.signRequest(UnsignedRequest.noBody("GET", uri, headers = Map.empty)) - signedRequest - .toHttp4sRequest[F] - .flatMap(req => client.run(req).use(retrievePublicKey)) - } + override def getPublicKey(appUUID: UUID): F[Option[PublicKey]] = cache + .cachingF(appUUID)(Some(configuration.getTimeToLive.seconds)) { + Sync[F] + .defer { + val uri = new URI(configuration.getBaseUrl + getRequestUrlPath(appUUID)) + val signedRequest = signer.signRequest(UnsignedRequest.noBody("GET", uri, headers = Map.empty)) + signedRequest + .toHttp4sRequest[F] + .flatMap(req => client.run(req).use(retrievePublicKey)) + } + .guaranteeCase { + case Outcome.Succeeded(res) => + res.flatMap { + case Some(_) => Async[F].unit + case None => cache.remove(appUUID) + } + case _ => cache.remove(appUUID) + } + .memoize + } + .flatten + private def retrievePublicKey(mauthPublicKeyFetcher: Response[F]): F[Option[PublicKey]] = { mauthPublicKeyFetcher.status match { case Status.Ok => @@ -82,9 +97,9 @@ object MauthPublicKeyProvider { // this provides a default implementation of the cache to be used with the public key provider, and frees the user to // inject their own cache - implicit def defaultCache[F[_]: Sync]: Cache[F, String, Option[PublicKey]] = - CaffeineCache[F, String, Option[PublicKey]]( - Caffeine.newBuilder().build[String, Entry[Option[PublicKey]]]() + implicit def defaultCache[F[_]: Sync]: Cache[F, UUID, Option[PublicKey]] = + CaffeineCache[F, UUID, Option[PublicKey]]( + Caffeine.newBuilder().build[UUID, Entry[Option[PublicKey]]]() ) } diff --git a/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala index 150eb32b..9cb1d5e4 100644 --- a/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala +++ b/modules/mauth-authenticator-http4s/src/test/scala/com/mdsol/mauth/http4s/MauthPublicKeyProviderSuite.scala @@ -1,6 +1,6 @@ package com.mdsol.mauth.http4s -import cats.effect.IO +import cats.effect.{IO, Ref} import com.github.benmanes.caffeine.cache.Caffeine import com.mdsol.mauth.test.utils.{FakeMAuthServer, PortFinder, TestFixtures} import com.mdsol.mauth.util.MAuthKeysHelper @@ -15,6 +15,9 @@ import scalacache.caffeine.CaffeineCache import scalacache.{Cache, Entry} import java.security.PublicKey +import cats.implicits._ + +import java.util.UUID class MauthPublicKeyProviderSuite extends CatsEffectSuite { @@ -24,17 +27,20 @@ class MauthPublicKeyProviderSuite extends CatsEffectSuite { private val MAUTH_URL_PATH = "/mauth/v1" private val SECURITY_TOKENS_PATH = "/security_tokens/%s.json" - private val cCache = Caffeine.newBuilder().build[String, Entry[Option[PublicKey]]]() - implicit val caffeineCache: Cache[IO, String, Option[PublicKey]] = CaffeineCache[IO, String, Option[PublicKey]](underlying = cCache) + private val cCache = Caffeine.newBuilder().build[UUID, Entry[IO[Option[PublicKey]]]]() + implicit val caffeineCache: Cache[IO, UUID, IO[Option[PublicKey]]] = CaffeineCache[IO, UUID, IO[Option[PublicKey]]](underlying = cCache) override def beforeEach(context: BeforeEach): Unit = { super.beforeEach(context) cCache.invalidateAll() } + + val requestCounter: Ref[IO, Int] = Ref.unsafe(0) + def executeRequest(uuid: String, response: IO[Response[IO]]): HttpApp[IO] = HttpRoutes .of[IO] { - case GET -> Root / "mauth" / "v1" / "security_tokens" / appId if appId == s"$uuid.json" => response + case GET -> Root / "mauth" / "v1" / "security_tokens" / appId if appId == s"$uuid.json" => requestCounter.update(_ + 1) *> response } .orNotFound @@ -61,6 +67,60 @@ class MauthPublicKeyProviderSuite extends CatsEffectSuite { ) } + test("MauthPublicKeyProvider handles multiple calls without making multiple requests when the call succeeds") { + val client = Client.fromHttpApp(executeRequest(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString, Ok(FakeMAuthServer.mockedMauthTokenResponse()))) + + val provider = new MauthPublicKeyProvider[IO]( + getMAuthConfiguration, + signer = signer, + client = client + ) + + for { + _ <- requestCounter.set(0) + _ <- List + .fill(100)( + provider.getPublicKey( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID + ) + ) + .parUnorderedSequence + _ <- provider + .getPublicKey( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID + ) + .guarantee(requestCounter.get.assertEquals(1)) + } yield () + } + + test("MauthPublicKeyProvider handles multiple calls but will re-call the API if the request fails") { + val client = Client.fromHttpApp(executeRequest(FakeMAuthServer.EXISTING_CLIENT_APP_UUID.toString, IO(Response[IO](status = Unauthorized)))) + + val provider = new MauthPublicKeyProvider[IO]( + getMAuthConfiguration, + signer = signer, + client = client + ) + + for { + _ <- requestCounter.set(0) + _ <- List + .fill(100)( + provider.getPublicKey( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID + ) + ) + .parUnorderedSequence + _ <- provider + .getPublicKey( + FakeMAuthServer.EXISTING_CLIENT_APP_UUID + ) + .guarantee(requestCounter.get.map { count => + assert(count > 1) + }) + } yield () + } + test("fail on invalid response from MAuth Server") { runTest( IO(Response[IO](status = Unauthorized)), diff --git a/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java b/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java index 905d2ffe..b1c32f29 100644 --- a/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java +++ b/modules/mauth-test-utils/src/main/java/com/mdsol/mauth/test/utils/FakeMAuthServer.java @@ -42,6 +42,9 @@ public static void return200() { .withHeader(HttpHeaders.CACHE_CONTROL, "max-age=3600, private"))); } + public static void verifyNumberOfRequests(int count) { + WireMock.verify(count, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/mauth/v1/security_tokens/" + EXISTING_CLIENT_APP_UUID.toString() + ".json"))); + } public static void return401() { WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo("/mauth/v1/security_tokens/" + NON_EXISTING_CLIENT_APP_UUID.toString() + ".json")) .willReturn(WireMock.aResponse().withStatus(401).withBody("Invalid headers"))); diff --git a/project/Dependencies.scala b/project/Dependencies.scala index e38d23d6..63cf16ae 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -14,6 +14,7 @@ object Dependencies extends DependencyUtils { } val akkaHttp: ModuleID = "com.typesafe.akka" %% "akka-http" % Version.akkaHttp + val akkaHttpCache: ModuleID = "com.typesafe.akka" %% "akka-http-caching" % Version.akkaHttp val akkaStream: ModuleID = "com.typesafe.akka" %% "akka-stream" % Version.akka val apacheHttpClient: ModuleID = "org.apache.httpcomponents" % "httpclient" % "4.5.13" val bouncyCastlePkix: ModuleID = "org.bouncycastle" % "bcpkix-jdk15on" % "1.70"