Jots Jots

http4s

The jots-http4s module provides an integration with http4s servers and clients. There is an AuthMiddleware for verifying and decoding tokens in incoming requests. There's also support for refreshing verification by periodically fetching a JwkSet from an HTTP endpoint with a Client.

Getting Started

To get started with sbt, add the following line to your build.sbt file.

libraryDependencies += "se.vlovgr" %% "jots-http4s" % "0.4.0"

If you are using Scala.js or Scala Native, replace the %% with %%% above.

Request Headers

There is support for both reading and writing SignedJwt as Authorization: Bearer <token> headers from and to requests. In the following example, we define a SignedJwt for demonstration purposes using syntax from the testing module. We then proceed to create a request with the SignedJwt in the headers and then read it back again.

import cats.effect.IO
import jots.http4s.*
import jots.SignedJwt
import jots.testing.syntax.*
import org.http4s.Request

val signedJwt: SignedJwt =
  signedJwt"eyJ0eXAiOiJKV1QiLCJraWQiOiI2MzRjODBhMy0zN2Y5LTQyYmMtYTY1Ny0wYmY0Zjc1OWIxZTMiLCJhbGciOiJFUzI1NiJ9.eyJ1c2VySWQiOiI4ZDNiYmQxNC1kZmQ5LTQ3ZmEtYWFiNC1kNzZkYWYwMGI0ZjEiLCJleHAiOjMzNDUwNjI0MDAsImlhdCI6MTc2NzIyNTYwMH0.RRqlOw-CuSgt7-24kzsbVs5Te4WHhuOCzEVWsMZEtQTsHr2_Hkjk4qjE3PaEgFcYCOnsbs20QNQZZ5KSWm5bUQ"

val requestWithSignedJwt: Request[IO] =
  Request[IO]().withHeaders(signedJwt)

val signedJwtFromRequest: Option[SignedJwt] =
  requestWithSignedJwt.headers.get[SignedJwt]

Auth Middleware

The JwtAuthMiddleware can be used to create AuthMiddleware for servers to parse, verify and decode tokens from incoming Authorization: Bearer <token> request headers. If the token is missing or invalid, a 401 Unauthorized response is sent along with a WWW-Authenticate header detailing the issue.

import cats.syntax.all.*
import io.circe.Decoder
import jots.crypto.SecretKey
import jots.JwtDecoder
import jots.JwtHmacAlgorithm
import jots.JwtVerificationBuilder
import org.http4s.AuthedRoutes
import org.http4s.HttpRoutes
import org.http4s.dsl.io.*

final case class UserJwt(id: String)

object UserJwt {
  given Decoder[UserJwt] =
    Decoder.forProduct1("userId")(apply)

  given JwtDecoder[UserJwt] =
    JwtDecoder.decodeClaims
}

val routes: IO[HttpRoutes[IO]] =
  for {
    secretKey <- SecretKey("gbxZ8rjekZmYQxh24wsKcaUqPuBe7jg6").liftTo[IO]
    verification <- JwtVerificationBuilder.default[IO].hmac(JwtHmacAlgorithm.HS256, secretKey).build
    authMiddleware = JwtAuthMiddleware[IO, UserJwt](verification)
    authedRoutes = AuthedRoutes.of[UserJwt, IO] {
      case GET -> Root / "identity" as user => Ok(user.id)
    }
  } yield authMiddleware(authedRoutes)

We should take care to not put secrets, like SecretKey, in source code.

Note the middleware protects every incoming request, so public routes should be combined before the authenticated routes. This normally looks something along the lines of: publicRoutes <+> authMiddleware(authedRoutes). In case it is not done correctly, public routes will always get a 401 Unauthorized without a token present.

Tokens are rejected when verification raises a JwtException, or when decoding fails with a DecodingFailure. Other errors are raised as normal, so the server's error handling applies (500 Internal Server Error by default). Custom verifications can raise a JwtException to have the middleware respond with a 401 Unauthorized.

Refreshing Verification

There is RefreshingJwtVerification with support for periodically fetching a JwkSet from an HTTP endpoint and refreshing verification. The following example shows how to create a RefreshingJwtVerification instance, which accepts tokens issued by https://example.auth0.com/ for the https://api.example.com audience. Note the example allows all supported asymmetric algorithms and uses the default refresh settings.

import cats.effect.Resource
import jots.JwtAsymmetricAlgorithm
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.syntax.all.*

val refreshingJwtVerification: Resource[IO, RefreshingJwtVerification[IO]] =
  for {
    client <- EmberClientBuilder.default[IO].build
    uri = uri"https://example.auth0.com/.well-known/jwks.json"
    refreshing <- RefreshingJwtVerificationBuilder
      .refreshWith(client, uri) { jwkSet =>
        JwtVerificationBuilder
          .default[IO]
          .jwkSet(JwtAsymmetricAlgorithm.All, jwkSet)
          .withAcceptedAudiences("https://api.example.com")
          .withAcceptedIssuers("https://example.auth0.com/")
          .build
      }
      .build
  } yield refreshing

Identity providers commonly sign tokens for many applications using the same keys. Make sure to set the accepted audiences, and preferably also the accepted issuers. Tokens with an audience (aud) are rejected unless the audience has been accepted.

The function passed to refreshWith creates the JwtVerification for each retrieved JwkSet, so the allowed algorithms (e.g. JwtRsaAlgorithm.All) and verifications are customized as with any JwtVerificationBuilder.

RefreshingJwtVerification extends JwtVerification, so it works everywhere JwtVerification is required. We also have the keys function to retrieve the current JwkSet. Note all functions semantically block until an initial key set is available (or an error occurred fetching the initial key set).

It is possible to directly use RefreshingJwtVerification with JwtAuthMiddleware.

val refreshingJwtVerificationRoutes: Resource[IO, HttpRoutes[IO]] =
  for {
    verification <- refreshingJwtVerification
    authMiddleware = JwtAuthMiddleware[IO, UserJwt](verification)
    authedRoutes = AuthedRoutes.of[UserJwt, IO] {
      case GET -> Root / "identity" as user => Ok(user.id)
    }
  } yield authMiddleware(authedRoutes)

Missing Key Refreshes

Tokens reference the key they were signed with using the kid (Key ID) in the token header, and verification selects the key with a matching kid from the current JwkSet. Since signing keys are rotated, a token can reference a key we have not yet retrieved, in which case we refresh the key set instead of failing right away.

Note tokens without a kid in the header are rejected without requesting extra refreshes.

Default Refresh Settings

The RefreshingJwtVerificationBuilder can be used to customize the following defaults.

Following is an example of how to customize the default settings.

import jots.JwtRsaAlgorithm
import org.typelevel.log4cats.slf4j.Slf4jLogger
import scala.concurrent.duration.*

val refreshingJwtVerificationBuilderCustom: Resource[IO, RefreshingJwtVerification[IO]] =
  for {
    logger <- Slf4jLogger.create[IO].toResource
    client <- EmberClientBuilder.default[IO].build
    uri = uri"https://example.auth0.com/.well-known/jwks.json"
    refreshing <- RefreshingJwtVerificationBuilder
      .refreshWith(client, uri) { jwkSet =>
        JwtVerificationBuilder
          .default[IO]
          .jwkSet(JwtRsaAlgorithm.All, jwkSet)
          .withAcceptedAudiences("https://api.example.com")
          .withAcceptedIssuers("https://example.auth0.com/")
          .build
      }
      .withRefreshInterval(30.minutes)
      .withMinRefreshIntervalOnMissingKey(10.minutes)
      .withLogger(logger)
      .build
  } yield refreshing