Http4s AuthedRoutes support in Http4sServerInterpreter

Hi,

Until now I was used to build HttpRoutes from my tapir endpoint definitions. These endpoints when requiring authentication were expecting an auth token (bearer) input.
Nonetheless, I currently have another need that is to use a Http4s library (http4s-pac4j) that performs some authentication mechanism but expects AuthedRoutes instead of HttpRoutes.

I didn’t find support for AuthedRoutes in tapir and I didn’t know where to handle this need, so I admit I had a little trouble figuring out how to do it. But, I finally have a working sample.

// For information :
type AuthedRoutes[T, F[_]] = Kleisli[OptionT[F, *], AuthedRequest[F, T], Response[F]]
type HttpRoutes[F[_]] = Http[OptionT[F, *], F]
type Http[F[_], G[_]] = Kleisli[OptionT[F, *], Request[G], Response[G]]
  import org.http4s.AuthedRoutes

  case class AuthedIn[In, AuthContext](input: In, context: AuthContext)

  val sampleEndpoint =
    endpoint.get
      .in("sample1")
      .out(stringBody)
      .description("Sample 1 endpoint")
      .name("Sample1")

  def sample1Route[F[_]: Async, T]: AuthedRoutes[T, F] =
    CustomHttp4sServerInterpreter[F]()
      .toAuthedRoutes[T](
        withAuthedContext(sampleEndpoint)
          .serverLogic(p => Async[F].pure(Right(s"auth data 1: ${p}")))
      )

  private def withAuthedContext[S, I, E, O, R, AuthContext](
      endpoint: Endpoint[S, I, E, O, R]
  ): Endpoint[S, AuthedIn[I, AuthContext], E, O, R] =
    endpoint
      .in(extractFromRequest { (req: ServerRequest) =>
        req
          .attribute(new AttributeKey[AuthContext]("authContext"))
          .getOrElse(throw new RuntimeException("auth context not found, should never happen except if attribute key is wrong"))
      })
      .mapInTo[AuthedIn[I, AuthContext]]

package sttp.tapir.server.http4s

import cats.data.Kleisli
import cats.data.OptionT
import cats.effect.Async
import cats.effect.std.Queue
import cats.implicits._
import fs2.Pipe
import fs2.Stream
import org.http4s._
import org.http4s.headers.`Content-Length`
import org.http4s.server.websocket.WebSocketBuilder2
import org.http4s.websocket.WebSocketFrame
import org.typelevel.ci.CIString
import sttp.capabilities.WebSockets
import sttp.capabilities.fs2.Fs2Streams
import sttp.tapir._
import sttp.tapir.integ.cats.effect.CatsMonadError
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.interceptor.RequestResult
import sttp.tapir.server.interceptor.reject.RejectInterceptor
import sttp.tapir.server.interpreter.BodyListener
import sttp.tapir.server.interpreter.FilterServerEndpoints
import sttp.tapir.server.interpreter.ServerInterpreter
import sttp.tapir.server.model.ServerResponse

trait CustomHttp4sServerInterpreter[F[_]] extends Http4sServerInterpreter[F] {

  def toAuthedRoutes[T](se: ServerEndpoint[Fs2Streams[F], F]): AuthedRoutes[T, F] =
    toAuthedRoutes[T](List(se))

  def toAuthedRoutes[T](serverEndpoints: List[ServerEndpoint[Fs2Streams[F], F]]): AuthedRoutes[T, F] =
    toAuthedRoutes(serverEndpoints, None)

  private def toAuthedRoutes[T](
      serverEndpoints: List[ServerEndpoint[Fs2Streams[F] with WebSockets, F]],
      webSocketBuilder: Option[WebSocketBuilder2[F]]
  ): AuthedRoutes[T, F] = {
    implicit val monad: CatsMonadError[F]                             = new CatsMonadError[F]
    implicit val bodyListener: BodyListener[F, Http4sResponseBody[F]] = new Http4sBodyListener[F]

    val interpreter = new ServerInterpreter[Fs2Streams[F] with WebSockets, F, Http4sResponseBody[F], Fs2Streams[F]](
      FilterServerEndpoints(serverEndpoints),
      new Http4sRequestBody[F](http4sServerOptions),
      new Http4sToResponseBody[F](http4sServerOptions),
      RejectInterceptor.disableWhenSingleEndpoint(http4sServerOptions.interceptors, serverEndpoints),
      http4sServerOptions.deleteFile
    )

    Kleisli { (authedRequest: AuthedRequest[F, T]) =>
      val serverRequest =
        Http4sServerRequest(
          authedRequest.req,
          AttributeMap.Empty
            .put(new AttributeKey[T]("authContext"), authedRequest.context)
        )

      OptionT(interpreter(serverRequest).flatMap {
        case _: RequestResult.Failure         => none.pure[F]
        case RequestResult.Response(response) => serverResponseToHttp4s(response, webSocketBuilder).map(_.some)
      })
    }
  }

  // below is unchanged but methods were private in Http4sServerInterpreter

  private def serverResponseToHttp4s(
      response: ServerResponse[Http4sResponseBody[F]],
      webSocketBuilder: Option[WebSocketBuilder2[F]]
  ): F[Response[F]] = {
    implicit val monad: CatsMonadError[F] = new CatsMonadError[F]

    val statusCode = statusCodeToHttp4sStatus(response.code)
    val headers    = Headers(response.headers.map(header => Header.Raw(CIString(header.name), header.value)).toList)

    response.body match {
      case Some(Left(pipeF)) =>
        pipeF.flatMap { pipe =>
          webSocketBuilder match {
            case Some(wsb) => wsb.withHeaders(headers).build(pipe)
            case None =>
              monad.error(
                new Http4sInvalidWebSocketUse(
                  "Invalid usage of web socket endpoint without WebSocketBuilder2. " +
                    "Use the toWebSocketRoutes/toWebSocketHttp interpreter methods, " +
                    "and add the result using BlazeServerBuilder.withHttpWebSocketApp(..)."
                )
              )
          }
        }
      case Some(Right((entity, contentLength))) =>
        val headers2 = contentLength match {
          case Some(value) if response.contentLength.isEmpty => headers.put(`Content-Length`(value))
          case _                                             => headers
        }
        Response(status = statusCode, headers = headers2, body = entity).pure[F]

      case None => Response[F](status = statusCode, headers = headers).pure[F]
    }
  }

  private def statusCodeToHttp4sStatus(code: sttp.model.StatusCode): Status =
    Status.fromInt(code.code).getOrElse(throw new IllegalArgumentException(s"Invalid status code: $code"))

}

object CustomHttp4sServerInterpreter {

  def apply[F[_]]()(implicit _fa: Async[F]): CustomHttp4sServerInterpreter[F] =
    new CustomHttp4sServerInterpreter[F] {
      implicit override def fa: Async[F] = _fa
    }

  def apply[F[_]](serverOptions: Http4sServerOptions[F])(implicit _fa: Async[F]): CustomHttp4sServerInterpreter[F] =
    new CustomHttp4sServerInterpreter[F] {
      implicit override def fa: Async[F]                       = _fa
      override def http4sServerOptions: Http4sServerOptions[F] = serverOptions
    }
}

There are probably some improvements to perform to ensure typesafety and correctness (especially on the attribute key name match) but here is my main idea. May be it is possible to have a better integration so the endpoint itself is aware of this authentication context (here it is done just before giving the endpoint to the server interpreter instance).

I don’t know if AuthedRoutes support is welcome in the ServerInterpreter. I don’t know either if it is the best way to meet this need (I’d be glad to hear about a better idea) but if it is feel free to take inspiration of it. At least I hope it may help someone with the same need.

Regards,

I don’t have experience with AuthedRoutes in http4s, but I like the direction of proposed enhancements. One thing that makes me feel suspicious is putting serverLogic in the toAuthedRoutes conversion. It would be nice to have a stronger separation between where we define serverLogic, and the abstraction layer of http4s, but that’s not really a major concern.
By the way, http4s-pac4j library seems to be no longer maintained, did you take that into consideration?

Hi,

I tried another attempt to make it more friendly.

I use ContextRoutes instead of AuthedRoutes (AuthedRoutes is just a type alias).
The aim is to put the code inside the interpreter and to provide a friendly DSL when using it.

So we can, now, write:

  def sample1Route[F[_]: Async, T]: ContextRoutes[T, F] =
    CustomHttp4sServerInterpreter[F]()
      .withContext()
      .toContextRoutes(sampleEndpoint)(_.serverLogic(p => Async[F].pure(Right(s"auth data 1: ${p}"))))
final case class InputWithContext[In, Ctx](input: In, context: Ctx)

trait CustomHttp4sServerInterpreter[F[_]] extends Http4sServerInterpreter[F] {

  final class ContextRoutesBuilder[Ctx](name: String) {

    private val attrKey = new AttributeKey[Ctx](name)

    def toContextRoutes[S, I, E, O, R](
        endpoint: Endpoint[S, I, E, O, R],
        f: Endpoint[S, InputWithContext[I, Ctx], E, O, R] => List[ServerEndpoint[Fs2Streams[F], F]]
    )(implicit dummy: DummyImplicit): ContextRoutes[Ctx, F] = {

      val endpointWithContext =
        endpoint
          .in(extractFromRequest { (req: ServerRequest) =>
            req
              .attribute(attrKey)
              // should never happen since http4s had to build a ContextRequest with Ctx for ContextRoutes
              .getOrElse(throw new RuntimeException(s"context ${name} not found in the request"))
          })
          .mapInTo[InputWithContext[I, Ctx]]

      innerContextRoutes[Ctx](attrKey, f(endpointWithContext), None)
    }

    def toContextRoutes[S, I, E, O, R](endpoint: Endpoint[S, I, E, O, R])(
        f: Endpoint[S, InputWithContext[I, Ctx], E, O, R] => ServerEndpoint[Fs2Streams[F], F]
    ): ContextRoutes[Ctx, F] =
      toContextRoutes(endpoint, e => List(f(e)))
  }

  final def withContext[Ctx](name: String = "defaultContext"): ContextRoutesBuilder[Ctx] =
    new ContextRoutesBuilder[Ctx](name)

  private def innerContextRoutes[T](
      attributeKey: AttributeKey[T],
      serverEndpoints: List[ServerEndpoint[Fs2Streams[F] with WebSockets, F]],
      webSocketBuilder: Option[WebSocketBuilder2[F]]
  ): ContextRoutes[T, F] = {
    implicit val monad: CatsMonadError[F]                             = new CatsMonadError[F]
    implicit val bodyListener: BodyListener[F, Http4sResponseBody[F]] = new Http4sBodyListener[F]

    val interpreter = new ServerInterpreter[Fs2Streams[F] with WebSockets, F, Http4sResponseBody[F], Fs2Streams[F]](
      FilterServerEndpoints(serverEndpoints),
      new Http4sRequestBody[F](http4sServerOptions),
      new Http4sToResponseBody[F](http4sServerOptions),
      RejectInterceptor.disableWhenSingleEndpoint(http4sServerOptions.interceptors, serverEndpoints),
      http4sServerOptions.deleteFile
    )

    Kleisli { (contextRequest: ContextRequest[F, T]) =>
      val serverRequest =
        Http4sServerRequest(
          contextRequest.req,
          AttributeMap.Empty
            .put(attributeKey, contextRequest.context)
        )

      OptionT(interpreter(serverRequest).flatMap {
        case _: RequestResult.Failure         => none.pure[F]
        case RequestResult.Response(response) => serverResponseToHttp4s(response, webSocketBuilder).map(_.some)
      })
    }
  }

About http4s-pac4j I think it is maintained (last update has been done 3 weeks ago and the demo updated 2 weeks ago, just that it does not seem to be released, or only on sonatype). Moreover, this is a small project (small scope <1k lines) between http4s and pac4j so it is not expected to change that much or be an issue to fix if needed.

@mprevel Looks good :slight_smile: And thanks for explaining the state of http4s-pac4j. I think it’s worth extending our current Http4sServerInterpreter with your proposed changes for context support. Could you create a PR?