Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reverse routing with router #8

Open
shagoon opened this issue Aug 25, 2021 · 5 comments
Open

Reverse routing with router #8

shagoon opened this issue Aug 25, 2021 · 5 comments

Comments

@shagoon
Copy link

shagoon commented Aug 25, 2021

Hi,

I really like your project, I'm currently using it inside http4s (0.21). How would reverse routing work if routes are put inside a router? I need to use a router to make my auth-middleware (tsec) not respond with status 401 (Unauthorized) for any requests that are simply not found. I.e.:

Router {
  "" -> publicRoutes,
  "protected" -> protectedRoutes
}

Reverse routing obviously does not know about the prefix protected.

A hacky solution would be to rewind the PathInfoCaret by the length of the prefix (by a custom middleware) and include the prefix inside the route definitions, but that just doesn't feel right.

Any suggestions?

@mrdziuban
Copy link
Contributor

Hey @shagoon, I would suggest building the "protected" prefix into the routes themselves and foregoing the built-in http4s router prefixes, like this:

import cats.effect.IO
import cats.syntax.semigroupk._
import org.http4s.{HttpRoutes, Request}
import org.http4s.dsl.io._
import org.http4s.server.middleware.GZip
import routing._
import routing.http4s._

// Apply the "protected" prefix here
def protectedRoute[M <: Method, P](method: M)(f: Route[M, Unit] => Route[M, P]): Route[M, P] =
  f(method / "protected")

val protectedGetRoute = protectedRoute(Method.GET)(_ / "get-route")
val protectedPostRoute = protectedRoute(Method.POST)(_ / "post-route")

// Apply your tsec middleware here, note I'm using gzip just as an example
val protectedRoutes: HttpRoutes[IO] = GZip(Route.httpRoutes[IO](
  protectedGetRoute.handle(_ => (_: Request[IO]) => Ok("protected GET")),
  protectedPostRoute.handle(_ => (_: Request[IO]) => Ok("protected POST"))
))

val publicGetRoute = Method.GET / "get-route"
val publicPostRoute = Method.POST / "post-route"

val publicRoutes: HttpRoutes[IO] = Route.httpRoutes[IO](
  publicGetRoute.handle(_ => (_: Request[IO]) => Ok("public GET")),
  publicPostRoute.handle(_ => (_: Request[IO]) => Ok("public POST"))
)

// Combine your two `HttpRoutes` with cats semigroupk syntax
val routes: HttpRoutes[IO] = protectedRoutes <+> publicRoutes

Does that work for you?

@shagoon
Copy link
Author

shagoon commented Sep 14, 2021

Hey @mrdziuban , thanks for the reply. I think the problem is not related to tsec but is the same for http4s' AuthMiddleware. Maybe I did it wrong, but iirc, I had those two options:

  • return 404 for protected routes, if not authenticated
  • return 403 for any request for which no route exists (if not authenticated).

Http4s describes that in it's docs: https://http4s.org/v0.23/auth/. The only solution that did work for me was to use a Router. That returned 403 for anything below protected if not authenticated (no matter if there's a matching route or not) and 404 for any requests without a matching route outside of protected.

ATM I think my best bet is to give up the idea of a single source of truth and construct routes and reverse routes independently of each other, but utilising a common source of path segments.

A method that transforms a given route and a prefix into a new route below that prefix might be helpful, i.e.:

val routeForRouter = Method.GET / "get-route"
val routeForReverseRouting = "protected" / routeForRouter // might need something to provide a leading slash

That way, I could still utilise common path and query params, wich would be great (instead of having two locations in code that have to be kept in sync manually).

@mrdziuban
Copy link
Contributor

mrdziuban commented Sep 15, 2021

I may still be misunderstanding something around your specific use case -- is the tsec middleware (or the generic http4s AuthMiddleware) applied just to protectedRoutes, or is it applied to the full set of routes constructed by calling Router { ... }?

If it's the former, then combining publicRoutes and protectedRoutes with semigroupk syntax should be no different from calling Router { ... }, since it also returns an HttpRoutes instance.

If that still doesn't seem like it would work, then I think you could define a method similar to protectedRoute method from my snippet to produce the separate routes for your router and reverse routing. For example, this will give you back a tuple of both the prefixed and unprefixed routes:

def prefixedAndUnprefixedRoutes[M <: Method, P](method: M, prefix: String)(
  f: Route[M, Unit] => Route[M, P]
): (Route[M, P], Route[M, P]) =
  (f(method / prefix), f(method))

val (routeForReverseRouting, routeForRouter) =
  prefixedAndUnprefixedRoutes(Method.GET, "protected")(_ / "get-route")

@shagoon
Copy link
Author

shagoon commented Sep 15, 2021

Hi. If you take this code with a http4s AuthMiddleware that never sees an authenticated user:

case class User()

val noAuth = AuthMiddleware[F, User](Kleisli{(r: Request[F]) => OptionT.none[F, User]})

val r = HttpRoutes.of[F] {
  case GET -> Root / "a" => Ok("a")
} <+> noAuth {
  AuthedRoutes.of {
    case GET -> Root / "protected" / "b" as user => Ok("b")
  }
} <+> HttpRoutes.of[F] {
  case GET -> Root / "c" => Ok("c")
}

you get:

  • /a -> 200
  • /protected/b -> 401 (fine)
  • /c -> 401 (not fine)
  • /d -> 401 (also not fine)

If you change noAuth to

val noAuth = AuthMiddleware.withFallThrough[F, User](Kleisli{(r: Request[F]) => OptionT.none[F, User]})

you get:

  • /a -> 200
  • /protected/b -> 404 (not fine)
  • /c -> 200 (fine)
  • /d -> 404 (fine)

That's what I meant: you either get false 401 or false 404.

The middleware needs to be executed in order to find matching routes applied to it. The only way (I could figure out) to execute the middleware only for a given prefix is to use a Router:

case class User()

val noAuth = AuthMiddleware[F, User](Kleisli{(r: Request[F]) => OptionT.none[F, User]})

val r = HttpRoutes.of[F] {
  case GET -> Root / "a" => Ok("a")
} <+> Router (
  "protected" -> noAuth {
    AuthedRoutes.of {
      case GET -> Root / "b" as user => Ok("b")
    }
  }
) <+> HttpRoutes.of[F] {
  case GET -> Root / "c" => Ok("c")
}

r.orNotFound
  • /a -> 200
  • /protected/b -> 401
  • /c -> 200
  • /d -> 404

Thanks for your suggestion. I'll see, if it fits. Need to get rid of tapir first, which I also tried to use for generating reverse routes (by using a client interpreter). That did not turn out too well.

@mrdziuban
Copy link
Contributor

mrdziuban commented Sep 15, 2021

Ah I see, thanks for the example. I've been messing around with a bunch of options and can't get the correct behavior using AuthedRoutes, I think because it's not possible to know whether a request matches a protected route without performing the logic in the AuthMiddleware, which triggers either the false 401 or false 404.

That said, I was able to achieve the correct behavior with some custom logic to match an unauthed request against the protected routes before actually performing auth, and falling back to the public routes if none of the protected routes matched:

val publicA = Method.GET / "a"
val publicC = Method.GET / "c"

def protectedRoute[M <: Method, P](method: M)(f: Route[M, Unit] => Route[M, P]): Route[M, P] =
  f(method / "protected")

val protectedB = protectedRoute(Method.GET)(_ / "b")

def defineRoutes[F[_], T](authMiddleware: AuthMiddleware[F, T])(
  // Routes that require auth, defined so we can match a request before performing auth
  authedRoutes: PartialFunction[Request[F], AuthedRequest[F, T] => F[Response[F]]]
)(
  // Routes that don't require auth
  fallbackRoutes: PartialFunction[Request[F], F[Response[F]]]
)(implicit F: Applicative[F]): HttpRoutes[F] =
  Kleisli((req: Request[F]) =>
    // Check if any route in `authedRoutes` matches the unauthed request
    authedRoutes.lift(req).fold(
      // If not, pass through to `fallbackRoutes`
      OptionT(fallbackRoutes.lift(req).sequence))(
      // If so, call `authMiddleware` and pass the authed request to the route's handler function
      f => authMiddleware(Kleisli((authedReq: AuthedRequest[F, T]) => OptionT.liftF(f(authedReq)))).run(req)))

case class User()

val noAuth = AuthMiddleware[IO, User](Kleisli((_: Request[IO]) => OptionT.none[IO, User]))

val routes = defineRoutes[IO, User](noAuth) {
  case protectedB(_) => (_: AuthedRequest[IO, User]) => Ok("b")
} {
  case publicA(_) => Ok("a")
  case publicC(_) => Ok("c")
}

def getResponseCode(path: String): Int =
  routes.run(Request[IO](uri = Uri(path = path))).value.unsafeRunSync().fold(404)(_.status.code)

getResponseCode("/a") // 200
getResponseCode("/protected/b") // 401
getResponseCode("/c") // 200
getResponseCode("/d") // 404

It's admittedly kind of hacky, so I wouldn't blame you if you chose another solution, but hopefully it's somewhat helpful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

2 participants