Security improvements in tapir 0.19
Tapir is a declarative, type-safe web endpoints library, using which you can describe endpoints as immutable Scala values. Later, such values can be interpreted as an HTTP server, serverless handler, client or exposed as OpenAPI or AsyncAPI documentation.
The focus of version 0.19.0 are security-related features, which influenced the design of the central datatype in tapir: Endpoint
. An endpoint has a number of inputs that map values from the request (such as path components, query parameters, header values, body) and a number of outputs, which are mapped to the response. What’s new is that next to regular inputs, an Endpoint
now has a dedicated section for security-related inputs.
When interpreted as a server, these endpoints are decoded first, and are used to run the security logic, which should authenticate (or reject) the request. The result of the security logic is typically a value, such as an authenticated User
instance. What’s important is that this takes place before body decoding is done; hence, the body is read and parsed only if the request passed authentication.
It is also possible to combine an endpoint description with the security logic and store the result as a value. Such a base secure endpoint can be later re-used by extending it multiple times with various regular inputs. This was possible before, however in tapir 0.19, the design and associated types are simpler, improving the ergonomics and naming of this functionality.
As a final addition, while before it wasn’t possible to extend the error outputs once security logic has been provided, it is now possible to add new error output variants (arbitrary extensions are still not possible). But let’s take a look at an example!
First, we’re defining a function that accepts an authentication token and returns either an error (indicating authentication has failed) or a User
instance; database lookups are simulated with a Future
:
case class User(name: String)
case class AuthenticationToken(value: String)
case class AuthenticationError(code: Int)
def authenticate(token: AuthenticationToken) =
Future[Either[AuthenticationError, User]] {
if (token.value == "berries") Right(User("Papa Smurf"))
else if (token.value == "smurf") Right(User("Gargamel"))
else Left(AuthenticationError(1001))
}
Next, we’re defining our base secure endpoint, which has the security inputs and the security logic already defined; we’ll get a value of type PartialServerEndpoint
. The endpoint maps the security input to a bearer token and wraps the primitive values into our domain classes:
val secureEndpoint = endpoint
.securityIn(auth.bearer[String]().mapTo[AuthenticationToken])
// returning the authentication error code to the user
.errorOut(plainBody[Int].mapTo[AuthenticationError])
.serverSecurityLogic(authenticate)
Finally, we extend that base endpoint with our hello-endpoint-specific metadata, here corresponding to GET /hello?salutation=...
and providing a variant for the error type that the endpoint returns. We need a single hierarchy of errors, hence the authentication error has to be wrapped. Alternatively, we could have used Scala’s Either
.
The endpoint is equipped with the server logic (the value has type ServerEndpoint
), allowing it to be interpreted as a server straight away:
// the errors that might occur in the /hello endpoint -
// either a wrapped authentication error, or refusal to greet
sealed trait HelloError
case class AuthenticationHelloError(wrapped: AuthenticationError)
extends HelloError
case class NoHelloError(why: String) extends HelloError
// extending the base endpoint with hello-endpoint-specific inputs
val secureHelloWorldWithLogic: ServerEndpoint[Any, Future] =
secureEndpoint
.get
.in("hello")
.in(query[String]("salutation"))
.out(stringBody)
.mapErrorOut(AuthenticationHelloError)(_.wrapped)
// returning a 400 with the "why" field from the exception
.errorOutVariant[HelloError](
oneOfVariant(stringBody.mapTo[NoHelloError]))
// defining the remaining server logic
// (which uses the authenticated user)
.serverLogic { user => salutation =>
Future(
if (user.name == "Gargamel")
Left(NoHelloError(s"Not saying hello to ${user.name}!"))
else Right(s"$salutation, ${user.name}!")
)
}
The above example in a runnable form can be found in the ServerSecurityLogicAkka class.
The last security-related feature that has been added is the possibility to hide endpoints that need authentication. By default, if an endpoint needs e.g. an API key in the Authorization
header, and such a value is missing from the request, the tapir server interpreter will return a 401 Unauthorized
with the appropriate WWW-Authenticate
header. This is not always desired; you might want to return a 404 Not Found
instead, hiding information that such endpoints exist at all.
Handling decode failures (such as a missing or malformed input value) is the job of the DecodeFailureHandler
interceptor. An alternative implementation can be provided when configuring the server interpreter; the example below uses the akka-http interpreter:
val customServerOptions: AkkaHttpServerOptions = AkkaHttpServerOptions
.customInterceptors
.decodeFailureHandler(
DefaultDecodeFailureHandler.hideEndpointsWithAuth)
.options
AkkaHttpServerInterpreter(customServerOptions).toRoute(???)
Note that the existence of such endpoints might still be revealed using timing attacks. Also, working with hidden endpoints might be more difficult, as no feedback from the server is returned, such as information on which headers or query parameters are missing or malformed.
The security inputs are reflected in the type of the created values, adding an additional type parameter to the endpoint type. It now is: Endpoint[A, I, E, O, R]
, which might look complex, but captures all the necessary information that is needed to provide the security logic, server logic, or when interpreting as a client, to create a well-typed client call function. The meaning of the parameters is:
A
- type of data mapped by the authentication inputs. Might be simple values, such asString
, or better - mapped to a meaningful domain typeI
- type of data mapped by regular inputsE
- error output type, which maps to the response, typically to a 4xx status codeO
- output type, which maps to the response using 2xx status codesR
- additional requirements for interpreters of the endpoint, such as non-blocking streaming or websocket support
Earlier versions of tapir didn’t have the A
type parameter, so what about migration? There’s a type alias PublicEndpoint[I, E, O, R]
, which fixes the A
type to Unit
(empty input - no values are mapped from the request), and as the name suggests, should be used for endpoints that don’t contain any security logic.
Any endpoints created using past tapir versions can be considered public (as they contain no security logic), so to migrate and fix most of the compilation errors, it should be sufficient to replace Endpoint
with PublicEndpoint
. Some additional changes might be needed for endpoints with partially defined server logic - the old .serverLogicForCurrent
is now .serverSecurityLogic
. However, to leverage this functionality, you’ll need to move the security-related inputs to the appropriate section. Finally, see the release notes for more information.
More background on the design of the changes is available in the ADR and in the [GH issue](Auth / security enhancements GH issue). Give tapir a spin and let us know your feedback! There’s a number of examples available, which should help you get started. Or dive into the docs to see if tapir is a good fit for your use-case.