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

pl.touk.nussknacker.ui.security.oauth2.OAuth2AuthenticationResources.scala Maven / Gradle / Ivy

package pl.touk.nussknacker.ui.security.oauth2

import akka.http.scaladsl.marshalling.ToResponseMarshallable
import akka.http.scaladsl.model.StatusCodes.NotFound
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers.{HttpCookie, `Set-Cookie`}
import akka.http.scaladsl.server.directives.{AuthenticationDirective, SecurityDirectives}
import akka.http.scaladsl.server.{Directives, Route}
import com.typesafe.scalalogging.LazyLogging
import io.circe.Encoder
import io.circe.syntax.EncoderOps
import pl.touk.nussknacker.security.AuthCredentials.PassedAuthCredentials
import pl.touk.nussknacker.ui.security.CertificatesAndKeys
import pl.touk.nussknacker.ui.security.api.AuthenticationResources.defaultRealm
import pl.touk.nussknacker.ui.security.api._
import sttp.client3.SttpBackend
import sttp.model.HeaderNames
import sttp.model.headers.{AuthenticationScheme, WWWAuthenticateChallenge}
import sttp.tapir.EndpointInput.AuthType
import sttp.tapir._

import scala.collection.immutable.ListMap
import scala.concurrent.{ExecutionContext, Future}

class OAuth2AuthenticationResources(
    override val name: String,
    service: OAuth2Service[AuthenticatedUser, OAuth2AuthorizationData],
    override val configuration: OAuth2Configuration
)(implicit executionContext: ExecutionContext, sttpBackend: SttpBackend[Future, Any])
    extends AuthenticationResources
    with Directives
    with LazyLogging
    with AnonymousAccessSupport {

  import pl.touk.nussknacker.engine.util.Implicits.RichIterable

  override type CONFIG = OAuth2Configuration

  private val authenticator = OAuth2Authenticator(service)

  override def authenticate(): AuthenticationDirective[AuthenticatedUser] =
    SecurityDirectives.authenticateOAuth2Async(
      authenticator = authenticator,
      realm = realm
    )

  override def authenticate(authCredentials: PassedAuthCredentials): Future[Option[AuthenticatedUser]] = {
    authenticator.authenticate(authCredentials.value)
  }

  override def authenticationMethod(): EndpointInput[Option[PassedAuthCredentials]] =
    optionalOauth2AuthorizationCode(
      authorizationUrl = configuration.authorizeUrl.map(_.toString),
      // it's only for OpenAPI UI purpose to be able to use "Try It Out" feature. UI calls authorization URL
      // (e.g. Github) and then calls our proxy for Bearer token. It uses the received token while calling the NU API
      tokenUrl = Some(s"../authentication/${name.toLowerCase()}"),
    ).map(_.map(PassedAuthCredentials))(_.map(_.value))

  override protected val frontendStrategySettings: FrontendStrategySettings =
    configuration.overrideFrontendAuthenticationStrategy.getOrElse(
      FrontendStrategySettings.OAuth2(
        configuration.authorizeUrl.map(_.toString),
        configuration.authSeverPublicKey.map(CertificatesAndKeys.textualRepresentationOfPublicKey),
        configuration.idTokenNonceVerificationRequired,
        configuration.implicitGrantEnabled,
        configuration.anonymousUserRole.isDefined
      )
    )

  override protected lazy val additionalRoute: Route =
    pathEnd {
      parameters(Symbol("code"), Symbol("redirect_uri").?) { (authorizationCode, redirectUri) =>
        get {
          completeOAuth2Authenticate(authorizationCode, redirectUri)
        }
      } ~
        formFields(Symbol("code"), Symbol("redirect_uri").?) { (authorizationCode, redirectUri) =>
          (get | post) {
            completeOAuth2Authenticate(authorizationCode, redirectUri)
          }
        }
    }

  override def impersonationSupport: ImpersonationSupport = NoImpersonationSupport

  override def getAnonymousRole: Option[String] = configuration.anonymousUserRole

  private def completeOAuth2Authenticate(authorizationCode: String, redirectUri: Option[String]) = {
    determineRedirectUri(redirectUri) match {
      case Some(redirectUri) =>
        complete {
          oAuth2Authenticate(authorizationCode, redirectUri)
        }
      case None =>
        complete((NotFound, "Redirect URI must be provided either in configuration or in query params"))
    }
  }

  private def determineRedirectUri(redirectUriFromRequest: Option[String]): Option[String] = {
    val redirectUriFromConfiguration = configuration.redirectUri.map(_.toString)
    redirectUriFromRequest match {
      // when the redirect URI is the Swagger UI one, force to use the URI
      case Some(uri) if uri.endsWith("/docs/oauth2-redirect.html") =>
        Some(uri)
      case _ =>
        List(redirectUriFromRequest, redirectUriFromConfiguration).flatten.exactlyOne
    }
  }

  private def oAuth2Authenticate(authorizationCode: String, redirectUri: String): Future[ToResponseMarshallable] = {
    service
      .obtainAuthorizationAndAuthenticateUser(authorizationCode, redirectUri)
      .map { case (auth, _) =>
        val response = Oauth2AuthenticationResponse(auth.accessToken, auth.tokenType)
        val cookieHeader = configuration.tokenCookie
          .map { config =>
            `Set-Cookie`(
              HttpCookie(
                config.name,
                auth.accessToken,
                httpOnly = true,
                secure =
                  true, // consider making it configurable (with default value 'true') so one could setup development environment easier (by setting it to 'false')
                path = config.path,
                domain = config.domain,
                maxAge = auth.expirationPeriod.map(_.toSeconds),
              )
            )
          }
        ToResponseMarshallable(
          HttpResponse(
            entity = HttpEntity(ContentTypes.`application/json`, response.asJson.noSpaces),
            headers = cookieHeader.toList
          )
        )
      }
      .recover { case OAuth2ErrorHandler(ex) =>
        logger.debug("Retrieving access token error:", ex)
        toResponseReject(Map("message" -> "Retrieving access token error. Please try authenticate again."))
      }
  }

  private def toResponseReject(entity: Map[String, String]): ToResponseMarshallable = {
    HttpResponse(
      status = StatusCodes.BadRequest,
      entity = HttpEntity(ContentTypes.`application/json`, Encoder.encodeMap[String, String].apply(entity).spaces2)
    )
  }

  // The code below is copied from sttp.tapir.Tapir.auth.oauth2.authorizationCode with the only change that
  // the authorization header is optional
  private def optionalOauth2AuthorizationCode(
      authorizationUrl: Option[String],
      tokenUrl: Option[String]
  ): EndpointInput.Auth[Option[String], AuthType.OAuth2] = {
    EndpointInput.Auth[Option[String], AuthType.OAuth2](
      header[Option[String]](HeaderNames.Authorization)
        .map(optionalMapping(stringPrefixWithSpace(AuthenticationScheme.Bearer.name))),
      WWWAuthenticateChallenge.bearer(realm),
      EndpointInput.AuthType.OAuth2(authorizationUrl, tokenUrl, ListMap.empty, None),
      EndpointInput.AuthInfo.Empty
    )
  }

  private def optionalMapping[T](originalMapping: Mapping[T, T]) = {
    Mapping
      .fromDecode[Option[T], Option[T]] {
        case Some(value) => originalMapping.decode(value).map(Some(_))
        case None        => DecodeResult.Value(None)
      } {
        _.map(originalMapping.encode)
      }
  }

  private def stringPrefixWithSpace(prefix: String): Mapping[String, String] = {
    Mapping.stringPrefixCaseInsensitive(prefix + " ")
  }

  private def realm = configuration.realm.getOrElse(defaultRealm)
}

final case class Oauth2AuthenticationResponse(accessToken: String, tokenType: String)

object Oauth2AuthenticationResponse {

  implicit val encoder: Encoder[Oauth2AuthenticationResponse] = Encoder
    // Swagger UI uses snake case instead of camel case, so we produce the response in these two cases
    .forProduct4("accessToken", "tokenType", "access_token", "token_type")(r =>
      (r.accessToken, r.tokenType, r.accessToken, r.tokenType)
    )

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy