All Downloads are FREE. Search and download functionalities are using the official Maven repository.

plugins.authcallers.scala Maven / Gradle / Ivy

package otoroshi.plugins.authcallers

import akka.http.scaladsl.util.FastFuture
import akka.stream.Materializer
import akka.util.ByteString
import org.joda.time.DateTime
import otoroshi.env.Env
import otoroshi.models.{GlobalConfig, ServiceDescriptor}
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.script._
import otoroshi.utils.http.MtlsConfig
import otoroshi.utils.syntax.implicits._
import play.api.libs.json.{JsObject, JsString, JsValue, Json}
import play.api.libs.ws.DefaultBodyWritables.writeableOf_urlEncodedSimpleForm
import play.api.mvc.{Result, Results}

import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NoStackTrace

class ForceRetryException(token: String)
    extends RuntimeException(
      "Unauthorized call on OAuth2 API, new token generated, add retry on your service to avoid seeing this message !"
    )
    with NoStackTrace

sealed trait OAuth2Kind {
  def name: String
}

object OAuth2Kind {
  case object ClientCredentials extends OAuth2Kind { def name: String = "client_credentials" }
  case object Password          extends OAuth2Kind { def name: String = "password"           }
}

case class OAuth2CallerConfig(
    kind: OAuth2Kind,
    url: String,
    method: String,
    headerName: String,
    headerValueFormat: String,
    jsonPayload: Boolean,
    clientId: String,
    clientSecret: String,
    scope: Option[String],
    audience: Option[String],
    user: Option[String],
    password: Option[String],
    cacheTokenSeconds: FiniteDuration,
    tlsConfig: MtlsConfig
)

object OAuth2CallerConfig {
  def parse(json: JsValue): OAuth2CallerConfig = {
    OAuth2CallerConfig(
      kind = json
        .select("kind")
        .asOpt[String]
        .map {
          case "client_credentials" => OAuth2Kind.ClientCredentials
          case _                    => OAuth2Kind.Password
        }
        .getOrElse(OAuth2Kind.ClientCredentials),
      url = json.select("url").asOpt[String].getOrElse("https://127.0.0.1:8080/oauth/token"),
      method = json.select("method").asOpt[String].getOrElse("POST"),
      headerName = json.select("headerName").asOpt[String].getOrElse("Authorization"),
      headerValueFormat = json.select("headerValueFormat").asOpt[String].getOrElse("Bearer %s"),
      jsonPayload = json.select("jsonPayload").asOpt[Boolean].getOrElse(false),
      clientId = json.select("clientId").asOpt[String].getOrElse("--"),
      clientSecret = json.select("clientSecret").asOpt[String].getOrElse("--"),
      scope = json.select("scope").asOpt[String].filter(_.nonEmpty),
      audience = json.select("audience").asOpt[String].filter(_.nonEmpty),
      user = json.select("user").asOpt[String].filter(_.nonEmpty),
      password = json.select("password").asOpt[String].filter(_.nonEmpty),
      cacheTokenSeconds = json.select("cacheTokenSeconds").asOpt[Long].getOrElse(10L * 60L).seconds,
      tlsConfig = MtlsConfig.read(json.select("tlsConfig").asOpt[JsValue])
    )
  }
}

// MIGRATED
class OAuth2Caller extends RequestTransformer {

  override def name: String = "OAuth2 caller"

  override def description: Option[String] =
    s"""This plugin can be used to call api that are authenticated using OAuth2 client_credential/password flow.
       |Do not forget to enable client retry to handle token generation on expire.
       |
       |This plugin accepts the following configuration
       |
       |${Json.prettyPrint(defaultConfig.get)}
       |""".stripMargin.some

  override def defaultConfig: Option[JsObject] = Json
    .obj(
      "kind"              -> "the oauth2 flow, can be 'client_credentials' or 'password'",
      "url"               -> "https://127.0.0.1:8080/oauth/token",
      "method"            -> "POST",
      "headerName"        -> "Authorization",
      "headerValueFormat" -> "Bearer %s",
      "jsonPayload"       -> false,
      "clientId"          -> "the client_id",
      "clientSecret"      -> "the client_secret",
      "scope"             -> "an optional scope",
      "audience"          -> "an optional audience",
      "user"              -> "an optional username if using password flow",
      "password"          -> "an optional password if using password flow",
      "cacheTokenSeconds" -> "the number of second to wait before asking for a new token",
      "tlsConfig"         -> "an optional TLS settings object"
    )
    .some

  override def configRoot: Option[String] = "OAuth2Caller".some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformRequest)

  def getToken(key: String, config: OAuth2CallerConfig)(implicit
      env: Env,
      ec: ExecutionContext,
      mat: Materializer
  ): Future[Either[(String, Int), String]] = {
    val body: String = config.kind match {
      case OAuth2Kind.ClientCredentials if config.jsonPayload =>
        Json
          .obj(
            "client_id"     -> config.clientId,
            "client_secret" -> config.clientSecret,
            "grant_type"    -> "client_credentials"
          )
          .applyOnWithOpt(config.scope) { (json, scope) => json ++ Json.obj("scope" -> scope) }
          .applyOnWithOpt(config.audience) { (json, audience) => json ++ Json.obj("audience" -> audience) }
          .stringify
      case OAuth2Kind.Password if config.jsonPayload          =>
        val user: String     = config.user.getOrElse("--")
        val password: String = config.password.getOrElse("--")
        Json
          .obj(
            "client_id"     -> config.clientId,
            "client_secret" -> config.clientSecret,
            "grant_type"    -> "password",
            "username"      -> user,
            "password"      -> password
          )
          .applyOnWithOpt(config.scope) { (json, scope) => json ++ Json.obj("scope" -> scope) }
          .applyOnWithOpt(config.audience) { (json, audience) => json ++ Json.obj("audience" -> audience) }
          .stringify
      case OAuth2Kind.ClientCredentials                       =>
        s"client_id=${config.clientId}&client_secret=${config.clientSecret}&grant_type=client_credentials${config.scope
          .map(s => s"&scope=$s")
          .getOrElse("")}${config.audience.map(s => s"&audience=$s").getOrElse("")}"
      case OAuth2Kind.Password                                =>
        s"client_id=${config.clientId}&client_secret=${config.clientSecret}&grant_type=password&username=${config.user
          .getOrElse("--")}&password=${config.password.getOrElse("--")}${config.scope
          .map(s => s"&scope=$s")
          .getOrElse("")}${config.audience.map(s => s"&audience=$s").getOrElse("")}"
    }
    val ctype        = if (config.jsonPayload) "application/json" else "application/x-www-form-urlencoded"
    env.MtlsWs
      .url(config.url, config.tlsConfig)
      .withMethod(config.method)
      .withHttpHeaders("Content-Type" -> ctype)
      .withBody(body)
      .execute()
      .flatMap { resp =>
        val respBody = resp.body
        if (resp.status == 200) {
          if (resp.contentType.toLowerCase().equals("application/x-www-form-urlencoded")) {
            val body             = resp.body
              .split("&")
              .map { p =>
                val parts = p.split("=")
                (parts.head, parts.last)
              }
              .toMap
            val jsonBody         = JsObject(body.mapValues(JsString.apply))
            val token            = body.getOrElse("access_token", "--")
            val expires_in: Long = body.getOrElse("expires_in", config.cacheTokenSeconds.toSeconds.toString).toLong
            val expiration_date  = DateTime.now().plusSeconds(expires_in.toInt).toDate.getTime
            val newjsonBody      = jsonBody.as[JsObject] ++ Json.obj("expiration_date" -> expiration_date)
            env.datastores.rawDataStore
              .set(key, ByteString(newjsonBody.stringify), expires_in.seconds.toMillis.some)
              .map { _ =>
                Right(token)
              }
          } else {
            val bodyJson         = resp.json
            val token            = bodyJson.select("access_token").as[String]
            val expires_in: Long =
              bodyJson.select("expires_in").asOpt[Long].getOrElse(config.cacheTokenSeconds.toSeconds)
            val expiration_date  = DateTime.now().plusSeconds(expires_in.toInt).toDate.getTime
            val newjsonBody      = bodyJson.as[JsObject] ++ Json.obj("expiration_date" -> expiration_date)
            env.datastores.rawDataStore
              .set(key, ByteString(newjsonBody.stringify), expires_in.seconds.toMillis.some)
              .map { _ =>
                Right(token)
              }
          }
        } else {
          Left((respBody, resp.status)).future
        }
      }
  }

  def fetchRefreshTheToken(
      refreshToken: String,
      config: OAuth2CallerConfig
  )(implicit env: Env, ec: ExecutionContext): Future[JsValue] = {
    val ctype   = if (config.jsonPayload) "application/json" else "application/x-www-form-urlencoded"
    val builder =
      env.MtlsWs
        .url(config.url, config.tlsConfig)
        .withMethod(config.method)
        .withHttpHeaders("Content-Type" -> ctype)
    val future1 = if (config.jsonPayload) {
      builder.post(
        Json
          .obj(
            "refresh_token" -> refreshToken,
            "grant_type"    -> "refresh_token",
            "client_id"     -> config.clientId,
            "client_secret" -> config.clientSecret
          )
          .applyOnWithOpt(config.scope) { (json, scope) => json ++ Json.obj("scope" -> scope) }
          .applyOnWithOpt(config.audience) { (json, audience) => json ++ Json.obj("audience" -> audience) }
      )
    } else {
      builder.post(
        Map(
          "refresh_token" -> refreshToken,
          "grant_type"    -> "refresh_token",
          "client_id"     -> config.clientId,
          "client_secret" -> config.clientSecret
        )
          .applyOnWithOpt(config.scope) { (json, scope) => json ++ Map("scope" -> scope) }
          .applyOnWithOpt(config.audience) { (json, audience) => json ++ Map("audience" -> audience) }
      )(writeableOf_urlEncodedSimpleForm)
    }
    // TODO: check status code
    future1.map(_.json).map { json =>
      val rtok      = json.select("refresh_token").asOpt[String].getOrElse(refreshToken)
      val tokenbody = json.as[JsObject] ++ Json.obj("refresh_token" -> rtok)
      tokenbody
    }
  }

  def tryRenewToken(key: String, config: OAuth2CallerConfig)(implicit
      env: Env,
      ec: ExecutionContext
  ): Future[Either[String, String]] = {
    env.datastores.rawDataStore.get(key).flatMap {
      case None            => Left("no token found !").vfuture
      case Some(tokenBody) => {
        val tokenBodyJson = tokenBody.utf8String.parseJson.asObject
        tokenBodyJson.select("refresh_token").asOpt[String] match {
          case None               => Left("no refresh_token found !").vfuture
          case Some(refreshToken) =>
            fetchRefreshTheToken(refreshToken, config).flatMap { newTokenBody =>
              val rtok             = newTokenBody.select("refresh_token").asOpt[String].getOrElse(refreshToken)
              val expires_in: Long =
                newTokenBody.select("expires_in").asOpt[Long].getOrElse(config.cacheTokenSeconds.toSeconds)
              val expiration_date  = DateTime.now().plusSeconds(expires_in.toInt).toDate.getTime
              val newnewTokenBody  =
                newTokenBody.as[JsObject] ++ Json.obj("refresh_token" -> rtok, "expiration_date" -> expiration_date)
              val token            = newnewTokenBody.select("access_token").as[String]
              env.datastores.rawDataStore
                .set(key, ByteString(newnewTokenBody.stringify), expires_in.seconds.toMillis.some)
                .map { _ =>
                  Right(token)
                }
            }
        }
      }
    }
  }

  def isAlmostComplete(tokenBody: JsValue, config: OAuth2CallerConfig): Boolean = {
    val expires_in = tokenBody.select("expires_in").asOpt[Long].getOrElse(config.cacheTokenSeconds.toSeconds)
    val limit      = expires_in.toDouble * 0.1
    tokenBody.select("expiration_date").asOpt[Long] match {
      case None                      => false
      case Some(expiration_date_lng) => {
        val expiration_date = new DateTime(expiration_date_lng)
        val dur             = new org.joda.time.Duration(DateTime.now(), expiration_date)
        dur.toStandardSeconds.getSeconds <= limit
      }
    }
  }

  def computeKey(env: Env, config: OAuth2CallerConfig, descriptor: ServiceDescriptor): String =
    s"${env.storageRoot}:plugins:oauth-caller-plugin:${config.kind.name}:${config.url}:${config.clientId}:${config.user
      .getOrElse("--")}:${config.password.getOrElse("--")}:${descriptor.id}"

  override def transformRequestWithCtx(
      ctx: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    val config = OAuth2CallerConfig.parse(ctx.configFor(configRoot.get))
    val key    = computeKey(env, config, ctx.descriptor)
    env.datastores.rawDataStore.get(key).flatMap {
      case Some(tokenBody) =>
        val jsonToken = tokenBody.utf8String.parseJson
        val token     = jsonToken.select("access_token").asString
        if (isAlmostComplete(jsonToken, config)) {
          tryRenewToken(key, config)
        }
        Right(
          ctx.otoroshiRequest
            .copy(headers = ctx.otoroshiRequest.headers + (config.headerName -> config.headerValueFormat.format(token)))
        ).future
      case None            =>
        getToken(key, config).map {
          case Left((body, status)) =>
            Left(
              Results.Unauthorized(
                Json.obj("error" -> "unauthorized", "error_description" -> body, "error_status" -> status)
              )
            )
          case Right(token)         =>
            Right(
              ctx.otoroshiRequest.copy(headers =
                ctx.otoroshiRequest.headers + (config.headerName -> config.headerValueFormat.format(token))
              )
            )
        }
    }
  }

  override def transformResponseWithCtx(
      ctx: TransformerResponseContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpResponse]] = {
    val config = OAuth2CallerConfig.parse(ctx.configFor(configRoot.get))
    val key    = computeKey(env, config, ctx.descriptor)
    if (ctx.otoroshiResponse.status == 401) {
      tryRenewToken(key, config).flatMap {
        case Left(_)      => {
          getToken(key, config).flatMap {
            case Left((body, status)) =>
              Left(
                Results.Unauthorized(
                  Json.obj("error" -> "unauthorized", "error_description" -> body, "error_status" -> status)
                )
              ).vfuture
            case Right(token)         => Future.failed[Either[Result, HttpResponse]](new ForceRetryException(token))
          }
        }
        case Right(token) => Future.failed[Either[Result, HttpResponse]](new ForceRetryException(token))
      }
    } else {
      Right(ctx.otoroshiResponse).future
    }
  }
}

case class BasicAuthCallerConfig(username: String, password: String, headerName: String, headerValueFormat: String)

// MIGRATED
object BasicAuthCallerConfig {
  def parse(json: JsValue): BasicAuthCallerConfig = {
    BasicAuthCallerConfig(
      username = json.select("username").asOpt[String].filter(_.nonEmpty).getOrElse("--"),
      password = json.select("password").asOpt[String].filter(_.nonEmpty).getOrElse("--"),
      headerName = json.select("headerName").asOpt[String].getOrElse("Authorization"),
      headerValueFormat = json.select("headerValueFormat").asOpt[String].getOrElse("Basic %s")
    )
  }
}

class BasicAuthCaller extends RequestTransformer {

  override def name: String = "Basic Auth. caller"

  override def description: Option[String] =
    s"""This plugin can be used to call api that are authenticated using basic auth.
      |
      |This plugin accepts the following configuration
      |
      |${Json.prettyPrint(defaultConfig.get)}
      |""".stripMargin.some

  override def defaultConfig: Option[JsObject] = Json
    .obj(
      "username"          -> "the_username",
      "password"          -> "the_password",
      "headerName"        -> "Authorization",
      "headerValueFormat" -> "Basic %s"
    )
    .some

  override def configRoot: Option[String] = "BasicAuthCaller".some

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformRequest)

  override def transformRequestWithCtx(
      ctx: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    val config        = BasicAuthCallerConfig.parse(ctx.configFor(configRoot.get))
    val token: String = ByteString(s"${config.username}:${config.password}").encodeBase64.utf8String
    Right(
      ctx.otoroshiRequest.copy(headers =
        ctx.otoroshiRequest.headers + (config.headerName -> config.headerValueFormat.format(token))
      )
    ).future
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy