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

Add basic authentication support for http4s #1342

Merged
merged 45 commits into from
Mar 29, 2022

Conversation

zeal18
Copy link
Contributor

@zeal18 zeal18 commented Dec 15, 2021

This is an idea, how authentication could be implemented. It's enabled automatically based on presents of global securitySchemes and at least one security requirement for a handler's route. Check sample-http4s/authentication for an example of generated code and Http4sAuthenticationTest for a possible usage example

@github-actions github-actions bot added core Pertains to guardrail-core java-dropwizard Pertains to guardrail-java-dropwizard java-spring-mvc Pertains to guardrail-java-spring-mvc scala-akka-http Pertains to guardrail-scala-akka-http scala-dropwizard Pertains to guardrail-scala-dropwizard scala-endpoints Pertains to guardrail-scala-endpoints scala-http4s Pertains to guardrail-scala-http4s labels Dec 15, 2021
@blast-hardcheese
Copy link
Member

Please pardon the initial silence here -- I didn't have the time this weekend, I'll try to get to it sometime this week

Copy link
Member

@blast-hardcheese blast-hardcheese left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are to attempt to actually implement authentication in a general way, I think it is important that we follow the spec, as avoiding generated API churn is an explicit goal of this project.

While you are correct, supplying an additional type parameter is likely the correct direction to go, the similarities between authenticationMiddleware and mapRequest is so great, it is actually possible to implement this feature without a change to guardrail at all by changing the supplied F from IO to Kleisli[IO, Option[AuthContext], *].

As I commented elsewhere in this review, I think the primary concerns that need to be addressed are:

  • Instead of enabling/disabling authentication via Boolean, we need to explicitly thread through which kind of authentication is supplied, per route, with failover
  • If at all possible, source compatibility with http4s' existing authentication middleware would be very useful, as it would permit code reuse between existing and new implementations, as well as compatibility with already-published http4s middleware providers.
    • If this also means switching from HttpRoutes.of... to AuthedRoutes.of and composing the two implementations together via <+>, that may help keep reimplementation down, and utilize some of the infrastructure already provided to us by http4s.

Thank you for your initial effort here, it's great to see interest for first-class authentication support in guardrail, at Twilio this was implemented in a bespoke way for our Dropwizard generators, targeting internal Twilio authentication provider libraries. What are your thoughts to my above observations?

@@ -55,6 +55,7 @@ class Http4sServerGenerator private (version: Http4sVersion)(implicit Cl: Collec
private def this(Cl: CollectionsLibTerms[ScalaLanguage, Target]) = this(Http4sVersion.V0_23)(Cl)

val customExtractionTypeName: Type.Name = Type.Name("E")
val authContextTypeName: Type.Name = Type.Name("AuthContextT")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implies the intent of this type, but isn't defined anywhere

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, is this really a monad transformer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's definitely not a monad transformer, just an old OOP suffix notation popped up in my mind, will rename

@@ -50,6 +50,10 @@ object ServerGenerator {
for {
resourceName <- formatTypeName(className.lastOption.getOrElse(""), Some("Resource"))
handlerName <- formatTypeName(className.lastOption.getOrElse(""), Some("Handler"))
authenticationEnabled = securitySchemes.nonEmpty && routes.exists {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right to consider securitySchemes authoritative, but instead of just recording a boolean, we should be able to thread the exact information down into routes.

While it is useful to be able to plug any desired middleware into the route constructor, the more important thing to observe is that the semantics are guided by the oAPI spec, so if there are multiple (consider /basic and /oauth), they need to be handled effectively together.

Without that additional metadata, it's either difficult or impossible to implement the middleware without reverse-engineering the routing layer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, it's not necessary to have securitySchemes down in the routes class, because there is no guarantee that all of them or any of them are used in routes. A route in the OAS contains security schemes names it requires, this information should be enough (in case we don't care about the schemes types and give a user the responsibility to implement them)

@zeal18
Copy link
Contributor Author

zeal18 commented Dec 26, 2021

If we are to attempt to actually implement authentication in a general way, I think it is important that we follow the spec, as avoiding generated API churn is an explicit goal of this project.

While you are correct, supplying an additional type parameter is likely the correct direction to go, the similarities between authenticationMiddleware and mapRequest is so great, it is actually possible to implement this feature without a change to guardrail at all by changing the supplied F from IO to Kleisli[IO, Option[AuthContext], *].

As I commented elsewhere in this review, I think the primary concerns that need to be addressed are:

  • Instead of enabling/disabling authentication via Boolean, we need to explicitly thread through which kind of authentication is supplied, per route, with failover

  • If at all possible, source compatibility with http4s' existing authentication middleware would be very useful, as it would permit code reuse between existing and new implementations, as well as compatibility with already-published http4s middleware providers.

    • If this also means switching from HttpRoutes.of... to AuthedRoutes.of and composing the two implementations together via <+>, that may help keep reimplementation down, and utilize some of the infrastructure already provided to us by http4s.

Thank you for your initial effort here, it's great to see interest for first-class authentication support in guardrail, at Twilio this was implemented in a bespoke way for our Dropwizard generators, targeting internal Twilio authentication provider libraries. What are your thoughts to my above observations?

Thank you for the review, I‘ll come back after the holidays

@zeal18
Copy link
Contributor Author

zeal18 commented Jan 16, 2022

If we are to attempt to actually implement authentication in a general way, I think it is important that we follow the spec, as avoiding generated API churn is an explicit goal of this project.

While you are correct, supplying an additional type parameter is likely the correct direction to go, the similarities between authenticationMiddleware and mapRequest is so great, it is actually possible to implement this feature without a change to guardrail at all by changing the supplied F from IO to Kleisli[IO, Option[AuthContext], *].

yes, it is similar, but it's not the same. mapRequest applies to all routes and to distinguish them a user needs to use String argument which a source of bugs. Using Option[AuthContext] doesn't guarantee a user has Some on routes, which were authenticated and has the context, this could lead having .get in user code (leaks authentication info to user code, but we should keep it on our side). Moreover, mapRequest doesn't have auth kinds and scopes information.

As I commented elsewhere in this review, I think the primary concerns that need to be addressed are:

  • Instead of enabling/disabling authentication via Boolean, we need to explicitly thread through which kind of authentication is supplied, per route, with failover

I agree, but we don't need to use securitySchemes for that, because any OAS route contains a list of auth kinds with scopes.

  • If at all possible, source compatibility with http4s' existing authentication middleware would be very useful, as it would permit code reuse between existing and new implementations, as well as compatibility with already-published http4s middleware providers.

I think it's possible to reuse, but it's better not to, because the http4s auth works on completely another level and doesn't have an information about auth kinds and scopes. Probably it could be implemented grouping routes but routes could have several kinds of auth at the same time. It would be hard to group such routes:

  • route A with auth kinds [basic]
  • route B with auth kinds [basic, oauth]
  • route C with auth kinds [oauth]
    we could have different groups for routes A and C to authenticate them, but it's very hacky to wrap route B correctly reusing middlewares for basic and oauth, the only way I see is to implement another middleware which combines them special way.

I've got an idea while writing this comment, I'll try to experiment with it. ☺️

  • If this also means switching from HttpRoutes.of... to AuthedRoutes.of and composing the two implementations together via <+>, that may help keep reimplementation down, and utilize some of the infrastructure already provided to us by http4s.

I wouldn't move it to a user code, maybe we can do it internally if it helps

Thank you for your initial effort here, it's great to see interest for first-class authentication support in guardrail, at Twilio this was implemented in a bespoke way for our Dropwizard generators, targeting internal Twilio authentication provider libraries. What are your thoughts to my above observations?

Thank you for the review, I'll try to push it forward whenever I have time

@blast-hardcheese
Copy link
Member

blast-hardcheese commented Jan 19, 2022

Using Option[AuthContext] doesn't guarantee a user has Some on routes, which were authenticated and has the context, this could lead having .get in user code

When I've done this in the past, I've done something along the lines of

ctx.fold(respond.NotAuthorized) { auth =>
  respond.Ok(auth.username)
}

though I appreciate the observation that this isn't always obvious to the user.

It does raise a consideration though, which is that if the route only specifies 200/404, presumably auth failures would introduce an otherwise unspecified response code with an unspecified schema. Perhaps a UserError if we intend to fail with an error code that isn't mentioned in the specification (though that doesn't solve the body problem, I think it's a step in the right direction).

I agree, but we don't need to use securitySchemes for that, because any OAS route contains a list of auth kinds with scopes.

Good -- So long as we agree that Boolean is suboptimal, I don't have an opinion on how it is implemented, so long as it's implemented in a flexible and clear way. Looking at the spec, it seemed as though there was at best a loose correlation between the defined schemes at the root of the specification and the usage of those schemes in individual routes.

One of the objections I have to Boolean is that it removes our ability to verify and offer detailed error messages if we see something that doesn't make sense, to guide the user towards a resolution to their issue. If we agree, and you take that into account in your implementation, then I'm comfortable moving forward with it 😁

I wouldn't move it to a user code, maybe we can do it internally if it helps

I agree with this -- any careless modification could change fully functional production code to only partially implemented code without any compiler errors, which would be a huge detriment to users. Thanks for calling it out!

@zeal18
Copy link
Contributor Author

zeal18 commented Jan 30, 2022

Hi again! I've found some time to get back.
I've experimented a bit with http4s's middlewares without success. It's made for another use case and doesn't fit into OAS definition of security handling. The main problem is that OAS allows to define pretty complex authentication checks like:

security:    # (A AND B) OR (C AND D)
  - A
    B
  - C
    D

Another potential problem here is caching, for example in case (A AND B) OR (A AND C) we probably want to cache A result and, if B fails, reuse it to continue evaluating directly to C. Also http4s's middlewares hardcode response types and don't expose their decision to user code which makes inconsistency with the scheme (as you pointed on your comment).

So, in the latest implementation all decisions should be made on the used code side: it decides what to do with security schemes and scopes, implements caching and whatever optimisations make sense (like always evaluate basic before oauth). As a result it returns an Option (maybe we should replace it by Either to be able to provide more details about an error) which is passed directly to the handler and should be proceed on the used code side to chose correct response type and generate valid response body.

@blast-hardcheese blast-hardcheese added the minor Bump minor version label Feb 4, 2022
@codecov
Copy link

codecov bot commented Feb 4, 2022

Codecov Report

Merging #1342 (37da3a5) into master (8a98332) will increase coverage by 0.18%.
The diff coverage is 98.83%.

@@            Coverage Diff             @@
##           master    #1342      +/-   ##
==========================================
+ Coverage   82.50%   82.69%   +0.18%     
==========================================
  Files          75       75              
  Lines        5316     5402      +86     
  Branches      147      151       +4     
==========================================
+ Hits         4386     4467      +81     
- Misses        930      935       +5     
Impacted Files Coverage Δ
...dules/core/src/main/scala/dev/guardrail/Args.scala 100.00% <ø> (ø)
...ain/scala/dev/guardrail/core/extract/package.scala 93.75% <ø> (-0.37%) ⬇️
...i/src/main/scala/dev/guardrail/cli/CLICommon.scala 64.28% <85.71%> (+1.94%) ⬆️
...enerators/scala/http4s/Http4sServerGenerator.scala 95.65% <98.75%> (+0.80%) ⬆️
...les/core/src/main/scala/dev/guardrail/Common.scala 98.92% <100.00%> (ø)
...es/core/src/main/scala/dev/guardrail/Context.scala 100.00% <100.00%> (ø)
...re/src/main/scala/dev/guardrail/core/Tracker.scala 91.30% <100.00%> (-1.88%) ⬇️
...ala/dev/guardrail/generators/ServerGenerator.scala 100.00% <100.00%> (ø)
...la/dev/guardrail/generators/SwaggerGenerator.scala 74.78% <100.00%> (-0.22%) ⬇️
...rs/java/dropwizard/DropwizardServerGenerator.scala 94.59% <100.00%> (ø)
... and 5 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2cb2213...37da3a5. Read the comment docs.

Copy link
Member

@blast-hardcheese blast-hardcheese left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really good. I did a few stylistic tweaks:

  • avoiding unsafe* methods
  • preferring A => A function composition instead of maintaining structurally similar branches
  • reducing case (Some(_), Some(_)) into a single Option[(A, B)]

The one sticking point for me is the ergonomics around authMiddleware itself. Looking at a fairly common case of being able to answer a challenge via one or more A => IO[B] functions, as a user I'd expect to be able to (straightforwardly):

  • Attempt to execute auth challenges in parallel
  • Collect and enforce the "and" and "or" constraints

I'm sure I've gotten lost somewhere here, but I tried to do a mock implementation that would match the type signature. The following is what I ended up with:

  def lookupAuthedScopes(req: Request[IO], scopes: List[String]): IO[List[String]] =
    IO.pure(
      req.headers.get(CIString("dummy-scopes"))
        .map(_.head.value.split(",").intersect(scopes))
        .toList
        .flatten
    )

  def authMiddleware(schemes: NonEmptyList[NonEmptyMap[Resource.AuthSchemes, List[String]]], req: Request[IO]): IO[Option[List[String]]] = {
    println("Schemes:")
    schemes.toList.foreach(_.toSortedMap.foreach({ case (scheme, scopes) => println(s"  ${scheme.name} -> ${scopes.mkString(", ")}") }))
    schemes.collectFirstSomeM[IO, List[String]](_.toSortedMap.toList.flatTraverse({
      case (Resource.AuthSchemes.Basic1, scopes) =>
        lookupAuthedScopes(req, scopes)
      case (Resource.AuthSchemes.Basic2, scopes) =>
        lookupAuthedScopes(req, scopes)
    }).map(Option(_).filter(_.nonEmpty)))
  }

This additionally relies on the implementation agreeing with what the specification says in #!/components/securitySchemes, everything's kinda open-ended in that regard.

Do you have any guidance of how you were intending these functions be implemented? The tests are extremely narrow and immediately move away from the NonEmptyList/NonEmptyMap types, in favor of direct equality against known test values, which are not sufficient inspiration for how a user may adapt this to their environment.

@blast-hardcheese
Copy link
Member

Playing around a little bit more, in my example I've discovered that

schemes.collectFirstSomeM(...)

and

lookupAuthedScopes(...)

almost mesh with the type of AuthMiddleware from http4s, which would be a good entrypoint for reuse of existing libraries, or for users trying to migrate existing codebases.

If Kleisli[OptionT[F, *], Request[F], A] can be escorted carefully through NonEmptyList[NonEmptyMap[Scheme, Scopes]], there may be a good amount of code reuse and interoperation with the ecosystem there.

@zeal18
Copy link
Contributor Author

zeal18 commented Feb 6, 2022

This looks really good. I did a few stylistic tweaks:

  • avoiding unsafe* methods
  • preferring A => A function composition instead of maintaining structurally similar branches
  • reducing case (Some(_), Some(_)) into a single Option[(A, B)]

The one sticking point for me is the ergonomics around authMiddleware itself. Looking at a fairly common case of being able to answer a challenge via one or more A => IO[B] functions, as a user I'd expect to be able to (straightforwardly):

  • Attempt to execute auth challenges in parallel
  • Collect and enforce the "and" and "or" constraints

Current implementation moves responsibility of processing "and" and "or" to the user side which increases complexity and reduces ergonomics, but from the other hand it gives user more freedom to optimise the check. Do you think it would be better to move parallelisation and running logic to the guardrail side and only request from the user a bunch of authentication functions ((List[String], Request[IO]) => IO[Option[B]])?

I'm sure I've gotten lost somewhere here, but I tried to do a mock implementation that would match the type signature. The following is what I ended up with:

  def lookupAuthedScopes(req: Request[IO], scopes: List[String]): IO[List[String]] =
    IO.pure(
      req.headers.get(CIString("dummy-scopes"))
        .map(_.head.value.split(",").intersect(scopes))
        .toList
        .flatten
    )

  def authMiddleware(schemes: NonEmptyList[NonEmptyMap[Resource.AuthSchemes, List[String]]], req: Request[IO]): IO[Option[List[String]]] = {
    println("Schemes:")
    schemes.toList.foreach(_.toSortedMap.foreach({ case (scheme, scopes) => println(s"  ${scheme.name} -> ${scopes.mkString(", ")}") }))
    schemes.collectFirstSomeM[IO, List[String]](_.toSortedMap.toList.flatTraverse({
      case (Resource.AuthSchemes.Basic1, scopes) =>
        lookupAuthedScopes(req, scopes)
      case (Resource.AuthSchemes.Basic2, scopes) =>
        lookupAuthedScopes(req, scopes)
    }).map(Option(_).filter(_.nonEmpty)))
  }

This additionally relies on the implementation agreeing with what the specification says in #!/components/securitySchemes, everything's kinda open-ended in that regard.

Do you have any guidance of how you were intending these functions be implemented? The tests are extremely narrow and immediately move away from the NonEmptyList/NonEmptyMap types, in favor of direct equality against known test values, which are not sufficient inspiration for how a user may adapt this to their environment.

I've reimplemented your example to how I see it could work in a real case:

    final case class User(id: Long, name: String, scopes: List[String])
    def getUserFromDB(id: Long, password: String): IO[Option[User]] =
      IO.pure(Some(User(id, "John Smith", List("admin"))))

    def base64Decode(in: String): Option[String] = ???

    def extractCredentials(req: Request[IO]): Option[(Long, String)] =
      req.headers
        .get[Authorization]
        .collect { case Authorization(Credentials.Token(AuthScheme.Basic, token)) => token }
        .flatMap(base64Decode)
        .map(_.split(":").toList)
        .collect { case id :: password :: Nil => (id.toLong, password) } // unsafe throw

    def authMiddleware(schemes: NonEmptyList[NonEmptyMap[Resource.AuthSchemes, List[String]]], req: Request[IO]): IO[Option[User]] = {
      println("Schemes:")
      schemes.toList.foreach(_.toSortedMap.foreach({ case (scheme, scopes) => println(s"  ${scheme.name} -> ${scopes.mkString(", ")}") }))
      schemes
        .collectFirstSomeM[IO, User](
          _.toSortedMap.toList
            .map {
              case (Resource.AuthSchemes.Basic1, scopes) =>
                extractCredentials(req).flatTraverse { case (id, pass) => getUserFromDB(id, pass).map(_.filter(_.scopes.toSet == scopes.toSet)) }
              case (Resource.AuthSchemes.Basic2, scopes) =>
                extractCredentials(req).flatTraverse { case (id, pass) => getUserFromDB(id, pass).map(_.filter(_.scopes.toSet == scopes.toSet)) }
            }
            .reduce[IO[Option[User]]] {
              case (l, r) =>
                l.flatMap {
                  case Some(_)  => r
                  case None       => IO.pure(None)
                }
            }
        )
    }

Some notes after writing it:

  • it doesn't support parallelisation
  • it doesn't support caching: it will invoke getUserFromDB twice
  • it doesn't check that in the first check user id is the same as in the second (but in a real case different checks could use different user sources with different id's)
  • I think scopes should be Set instead of List
  • I think we should return Either[AuthError, A] instead of Option[A] to be able to distinguish different errors on the handler (in the user's code) and map them to different responses. It would be also helpful for logging and debugging. If it's redundant for a user, it's always easy to convert an Either to an Option on the user side.

While I wrote this I've realised that the implementation looks a bit complicated, especially when a real use case has only one authentication type. I would suggest to introduce a config parameter like "custom authentication" which would enable current 'complicated' implementation for a complicated case (with "and"s and "or"s, parallelisation requirement and etc.). But replace the current implementation by simplified (from user point of view) one: the code you wrote in the authMiddleware we can execute on the guardrail side and require from a user only a list of functions (List[String], Request[IO]) => IO[Option[B]] depending how many authentication types are defined in a spec.

Even in the simplified case the http4s AuthMiddleware wouldn't be applicable because it cuts out the user code returning hardcoded error responses directly from it, which makes it harder to customise bodies and could create an inconsistency with a spec.

@blast-hardcheese
Copy link
Member

Current implementation moves responsibility of processing "and" and "or" to the user side which increases complexity and reduces ergonomics, but from the other hand it gives user more freedom to optimise the check. Do you think it would be better [...]

to move parallelisation and running logic to the guardrail side [...]

Enforcing parallelized calls against some user code seems like it would be easier for the user to implement, which is good, at the expense of some (possibly nonexistent?) case where a user does not want that parallelism.

and only request from the user a bunch of authentication functions ((List[String], Request[IO]) => IO[Option[B]])?

I wonder if a better strategy wouldn't be to offer some guardrail-auth library that conforms to the expected type you've specified here, with adapters to known-working implementations of various authentication schemes (I really like bare-bones-digest, as an example. Zero dependency digest auth implementation).


As for ways to improve, I'm interested in your thought about what the user experience of Either[E, A] instead of Option[A] for the auth context would look like, I think it's also useful to communicate No Auth vs Failed Auth states.

I was experimenting with whether I liked the idea of reducing the NEL[NEM[Scheme, Scopes]] => F[A] to just (Scheme, Scopes) => F[A], and while it does make the code simpler (see the example at the end of this post), it would make guardrail-auth much more cumbersome to manage for the user (choosing different objects or methods that would otherwise be nearly identical but for the number of elements in a list in a specification file).

Thank you for the dialogue around what end-user code would look like, your implementation clears up a fair amount.


Idea for what the singleton scheme code may look like, though to reiterate, I don't believe this is worth the cost of an explosion of complexity in the to-be-designed guardrail-auth library.

    final case class User(id: Long, name: String, scopes: Set[String])
    def getUserFromDB(id: Long, password: String): IO[Option[User]] =
      IO.pure(Some(User(id, "John Smith", Set("admin"))))

    def base64Decode(in: String): Option[String] = ???

    def extractCredentials(req: Request[IO]): Option[(Long, String)] =
      req.headers
        .get[Authorization]
        .collect { case Authorization(Credentials.Token(AuthScheme.Basic, token)) => token }
        .flatMap(base64Decode)
        .map(_.split(":").toList)
        .collect { case id :: password :: Nil => (id.toLong, password) } // unsafe throw

    val authMiddleware: ((Resource.AuthSchemes, Set[String]), Request[IO]) => IO[Option[User]] = {
      case ((Resource.AuthSchemes.Basic, scopes), req) =>
        extractCredentials(req)
          .flatTraverse { case (id, pass) =>
            getUserFromDB(id, pass)
              .map(_.filter(u => scopes.diff(u.scopes).isEmpty)) // If the user contains at least the scopes requested
          }
    }

@zeal18
Copy link
Contributor Author

zeal18 commented Feb 7, 2022

I like the latest code example, I think it has enough simplicity and we should consider it as the main solution. For users who have special cases we can provide NEL[NEM[Scheme, Scopes]] => F[A] option through config. Also I'm thinking about a possibility to completely switch off the authentication on the guardrail side (at least to be compatible with the old implementations and don't force users to migrate). It's a good idea to provide a guardrail-auth library, but I think it should be an addition not a mandatory part.
The next steps I'll apply:

  • replace Option[A] result by Either[E, A]
  • introduce a config parameter with disable, simple and custom options considering the current implementation (with NEL and NEM) as a custom
  • implement simple one based on the latest code example

@blast-hardcheese
Copy link
Member

I just merged #1359, which will likely resolve the code coverage issue you're running into in this PR. I'm fine ignoring it for now, since we've been dancing around our coverage target for a while, pushed into the red by the http4s 0.22 and 0.23 tests being split in two until we drop 0.22 support. Feel free to rebase off master if you want to see green, there should be no merge conflicts.

@zeal18
Copy link
Contributor Author

zeal18 commented Feb 10, 2022

Thanks for notifying, I've rebased the branch and made a couple of steps further:

  • introduced CLI argument with 3 possible values: disable, simple and custom
  • Implemented simple authentication we discussed above
  • Refactored to use Set for scopes

Possible authentication parameters:

  • disable ignores security definitions and requirements
  • simple implements a part of authentication logic on guardrail side. It generates the following code in Resource companion object:
def authenticate[F[_]: Monad, AuthContext](middleware: (AuthSchemes, Set[String], Request[F]) => F[Option[AuthContext]], schemes: NonEmptyList[NonEmptyMap[AuthSchemes, Set[String]]], req: Request[F]): F[Option[AuthContext]] = {
  schemes.collectFirstSomeM[F, AuthContext](_.toNel.map({
    case (scheme, scopes) =>
      middleware(scheme, scopes, req)
  }).reduce[F[Option[AuthContext]]]({
    case (l, r) =>
      l.flatMap({
        case Some(_) =>
          r
        case None =>
          Applicative[F].pure(None)
      })
  }))
}

it's based on the code you suggested above with little adjustments. This approach allows to require from user a bit simpler middleware signature: middleware: (AuthSchemes, Set[String], Request[F]) => F[Option[AuthContext]]. Possible implementation on the user side becomes very close to what you suggested in the previous message (but I didn't compile it 😅):

final case class User(id: Long, name: String, scopes: Set[String])
def getUserFromDB(id: Long, password: String): IO[Option[User]] =
  IO.pure(Some(User(id, "John Smith", Set("admin"))))

def base64Decode(in: String): Option[String] = ???

def extractCredentials(req: Request[IO]): Option[(Long, String)] =
  req.headers
    .get[Authorization]
    .collect { case Authorization(Credentials.Token(AuthScheme.Basic, token)) => token }
    .flatMap(base64Decode)
    .map(_.split(":").toList)
    .collect { case id :: password :: Nil => (id.toLong, password) } // unsafe throw

val authMiddleware: (Resource.AuthSchemes, Set[String], Request[IO]) => IO[Option[User]] = {
  case (Resource.AuthSchemes.Basic, scopes, req) =>
    extractCredentials(req)
      .flatTraverse { case (id, pass) =>
        getUserFromDB(id, pass)
          .map(_.filter(u => scopes.diff(u.scopes).isEmpty)) // If the user contains at least the scopes requested
      }
}
  • custom authentication works like it was implemented previously (with (NonEmptyList[NonEmptyMap[Resource.AuthSchemes, List[String]]], Request[IO]) => IO[Option[User]]

The next steps could be:

  • Replace Option by Either on the authentication result to provide better error information and make it possible to map it to different response codes and bodies on a handler
  • fix bug processing global security requirements and merging them with local ones
  • adjust local security requirements type to allow empty list to be able to reset global requirement (OAS feature and described in the spec documentation)
  • improve authentication test coverage: cover better different authentication scenarios and failures

@blast-hardcheese blast-hardcheese dismissed their stale review February 12, 2022 22:50

Changes being implemented

Applicative[F].pure(l)
})
})
val nel = el.toNel
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One caveat of the NonEmptyMap is that it seems to preclude the "Optional Oauth2 Security" example, reproduced here:

security:
  - {}
  - petstore_auth:
    - write:pets
    - read:pets

This looks like an oversight from the first foray into authentication, so if you'd like to consider that case out of scope here I'm happy to take that on, if you're running out of time. I appreciate the number of iterations you've taken to this problem so far, just trying to make sure we merge something that isn't going to need more work immediately.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's true, I've wrote about the bug above. I would fix it before merging the change because it's not very useful with the problem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, I've checked the link and it's not the bug I mentioned, it's another use case related with optional requirements, anyway, I think it needs to be fixed)

Copy link
Contributor Author

@zeal18 zeal18 Feb 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as I wrote in the previous comment, the swagger library we use under the hood doesn't support this approach to define optional authentication. It seems there is the x-security-optional to cover the case, but it was a list of names which is a bit overwhelming and unclear. To make it a bit more clear, I've changed its type to Boolean and it could be defined just for a route to make its security requirements optional.

For the custom authentication we provide its value to the authenticationMiddleware and let the user to decide what to do with that: produce some special case of its AuthContext or just pass it through inside of the AuthContext to the handler and deal with that there.

For the simple authentication we replace Either by an Option for the handler but the authenticationMiddleware signature wasn't changed

@zeal18
Copy link
Contributor Author

zeal18 commented Feb 19, 2022

Hi again, I'm still here)
It seems I've finalised the whole change:

  • Option result has been replaced by Either, but only for simple authentication. For custom authentication we just do nothing 😜 because we move the responsibility to a user and the user could define any model he/she wants through AuthContext, we just pass it from an authentication middleware to a handler.
  • x-security-optional type has been changed to boolean (it wasn't used anyway) to use it as a workaround for the "Optional Oauth2 Security". The underlying swagger library we use doesn't support it and the x-security-optional comes to help
  • also fixed a possibility to disable global security requirements for particular paths

@blast-hardcheese
Copy link
Member

The underlying swagger library we use doesn't support it

Before moving forward with the review, I did a small experiment with:

diff --git a/modules/sample/src/main/resources/issues/issue1218.yaml b/modules/sample/src/main/resources/issues/issue1218.yaml
index 08a65de4..05751565 100644
--- a/modules/sample/src/main/resources/issues/issue1218.yaml
+++ b/modules/sample/src/main/resources/issues/issue1218.yaml
@@ -11,6 +11,10 @@ paths:
       responses:
         '204':
           description: No response
+      security:
+        - basic:
+          - "bar:basic"
+        - {}
       parameters:
         - name: refvec
           in: query
@@ -125,6 +129,10 @@ paths:
               type: integer
               format: int64
 components:
+  securitySchemes:
+    basic:
+      type: http
+      scheme: basic
   schemas:
     refvec:
       type: array

combined with

diff --git a/modules/core/src/main/scala/dev/guardrail/Common.scala b/modules/core/src/main/scala/dev/guardrail/Common.scala
index 47b5c90b..6445a953 100644
--- a/modules/core/src/main/scala/dev/guardrail/Common.scala
+++ b/modules/core/src/main/scala/dev/guardrail/Common.scala
@@ -72,6 +72,15 @@ object Common {
         .filter(_ != "/")

       paths = swagger.downField("paths", _.getPaths)
+      () = {
+        import scala.jdk.CollectionConverters._
+        swagger.unwrapTracker.getPaths.asScala.values.toList.flatMap(_.readOperations().asScala.toList).flatMap(_.getSecurity.asScala.toList).foreach(println)
+      }
       globalSecurityRequirements = NonEmptyList
         .fromList(swagger.downField("security", _.getSecurity).unwrapTracker)
         .flatMap(SecurityRequirements(_, SecurityOptional(swagger), SecurityRequirements.Global))

which, while not null safe at all and quite unreadable, it does show the expected states:

[info] class SecurityRequirement {
[info]     {basic=[bar:basic]}
[info] }
[info] class SecurityRequirement {
[info]     {}
[info] }

What is not supported? Is this some interaction with the automatic type-conversion in Tracker? If so, I can help -- that structure is still somewhat difficult to work with.

Since the case of - {} is actually in the documentation for the OpenAPI specification itself, I'd really like to get that case working, instead of adding a workaround that extends the specification.

Additionally, if x-security-optional is currently not used today (perhaps it was used for something internally at Twilio in the past, unsure), we can likely drop it, simplifying the user experience.

val securityRequirements = renderCustomSecurityRequirements(sr)
val authContextParam = param"${arg.paramName}"
val authTransformer: Term => Term = inner => q"""
authenticationMiddleware($securityRequirements, ${sr.optional}, req).flatMap { $authContextParam =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the last sticking point for me. ${sr.optional} being passed in as a Boolean into authenticationMiddleware is really confusing, as a user. If authentication is optional, shouldn't there just be a recover on whatever $authContextTypeName is returned? Some(Unauthorized) => None?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a stab at resolving this. There's something nagging at me about having both SecurityExposure and AuthImplementation passed in as separate parameters to a bunch of parameters. I've not changed any of the underlying logic, though reducing the number of Boolean's exposed to users is better. I'll come back to this soon (I'd like to get this merged and on its way), but offering here in case anyone is interested in taking a look.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't found a great way to resolve this, so I'm going to merge as is.

@blast-hardcheese blast-hardcheese force-pushed the auth-support branch 2 times, most recently from 241c567 to 1ffdd39 Compare March 25, 2022 07:37
@blast-hardcheese blast-hardcheese force-pushed the auth-support branch 2 times, most recently from b931ebb to 9539c23 Compare March 27, 2022 15:49
@blast-hardcheese
Copy link
Member

The last concern I had was whether these parameters made sense across Scala/Java, and for all the different supported frameworks. I'm alright with proceeding.

@blast-hardcheese blast-hardcheese merged commit 6aba741 into guardrail-dev:master Mar 29, 2022
@blast-hardcheese
Copy link
Member

Thanks for your patience, @zeal18, life has been getting in the way of getting 100% behind where we ended up by the end here.

@blast-hardcheese blast-hardcheese added the enhancement Functionality that has never existed in guardrail label Mar 29, 2022
@zeal18
Copy link
Contributor Author

zeal18 commented Mar 29, 2022

The last concern I had was whether these parameters made sense across Scala/Java, and for all the different supported frameworks. I'm alright with proceeding.

I thought about it, especially about the native. I think it become clearer over the time.

I've took a look at some of your improvements but had no chance to check them all, it ok for me by the way) It's nice that it's merged, that means I'll check how the idea works in our project very soon 🤤

Thanks for your patience, @zeal18, life has been getting in the way of getting 100% behind where we ended up by the end here.

thanks, it was nice to work on it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Pertains to guardrail-core enhancement Functionality that has never existed in guardrail java-dropwizard Pertains to guardrail-java-dropwizard java-spring-mvc Pertains to guardrail-java-spring-mvc minor Bump minor version scala-akka-http Pertains to guardrail-scala-akka-http scala-dropwizard Pertains to guardrail-scala-dropwizard scala-endpoints Pertains to guardrail-scala-endpoints scala-http4s Pertains to guardrail-scala-http4s
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants