Building simple Scala web application with OAuth2 Github login - part 2

In the first part we have prepared a simple web application using Scala with Tapir and Http4s. It can be found in this commit. In this part we’ll integrate our application with Github, providing OAuth2 login and some basic API integration.

Before we get started make sure to register a Github application. This article will guide you through the process: https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app

Authorization code grant

OAuth2 (described in rfc6749) defines four grant types: authorization code, implicit, resource owner password credentials, and client credentials. For authorizing end users using Github, we’ll use authorization code grant.

We won’t be discussing its details in this article, as it’s already described in detail in rfc6749 section 4.1. There’s also a great example in oauth.com playground.

In short, this is how the flow looks like from the application perspective, particularly when logging in using Github:

  • User visits a login endpoint, say /api/login-redirect
  • Application generates a redirect URL with all required parameters and redirects the user
  • User is redirected to Github login website, logs in and gets redirected back to our app
  • Application exchanges received authorization code for user token

From now on we can use the received token with Github API.

Now that we are familiar with the general concept, we can start the implementation.

Implementation

Endpoints

sttp-oauth2 provides a convenient way of hiding the RFC details. It’s already included in our Dependencies.scala file.

private val sttpOAuth2 = Seq(
  "com.ocadotechnology" %% "sttp-oauth2" % Versions.SttpOAuth2
)

Let’s get started by defining two endpoints:

  • Redirect endpoint: GET /api/login-redirect, takes no parameters, responds with 303 See Other response code and a Location header.
  • Post login endpoint: GET /api/post-login expects two parameters
    • code representing authorization code, to be exchanged for token in endpoint logic
    • state can later be used to improve security by introducing CSRF verification

Now let’s take those requirements and implement those endpoints using tapir.

Login endpoint can be implemented like this:

val loginRedirect: Endpoint[Unit, Unit, String, Any] =
  endpoint
    .get
    .in("api" / "login-redirect")
    .out(header[String]("Location"))
    .out(statusCode(sttp.model.StatusCode.SeeOther))  

Post login endpoint is also rather self explanatory and can be implemented following way:

val postLogin: Endpoint[(String, String), Unit, String, Any] =
  endpoint
    .get
    .in("api" / "post-login")
    .in(query[String]("code"))
    .in(query[String]("state"))
    .out(stringBody)

This is it, we already have our endpoints defined, but there’s still some space for improvement. Using simple types like String or tuples like (String, String) doesn’t help understanding the role of those types.

This can be improved by introducing value classes. Value class in Scala is a case class that wraps around another type without the need to allocate an extra object. It allows us to both add a specific name to primitive type, but also some additional logic (which is out of scope of this article), with no cost (with [some exceptions](some link here to point to exceptions)).

To improve our endpoint definitions, let’s introduce following value classes:

final case class AuthorizationCode(value: String) extends AnyVal
final case class State(value: String) extends AnyVal
final case class RedirectUrl(value: String) extends AnyVal

Our endpoints can be updated accordingly:

val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any] =
  endpoint
    .get
    .in("api" / "login-redirect")
    .out(header[RedirectUrl]("Location"))
    .out(statusCode(sttp.model.StatusCode.SeeOther))

val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any] =
  endpoint
    .get
    .in("api" / "post-login")
    .in(query[AuthorizationCode]("code"))
    .in(query[State]("state"))
    .out(stringBody)

Is it enough? Let’s try compiling it!

[error] (REDACTED) Cannot find a codec between types: List[String] and example.OAuthEndpoints.RedirectUrl, formatted as: sttp.tapir.CodecFormat.TextPlain.
[error] Did you define a codec for: example.OAuthEndpoints.RedirectUrl?
[error] Did you import the codecs for: sttp.tapir.CodecFormat.TextPlain?
[error]       .out(header[RedirectUrl]("Location"))
[error]                               ^
[error] (REDACTED) Cannot find a codec between types: List[String] and example.OAuthEndpoints.AuthorizationCode, formatted as: sttp.tapir.CodecFormat.TextPlain.
[error] Did you define a codec for: example.OAuthEndpoints.AuthorizationCode?
[error] Did you import the codecs for: sttp.tapir.CodecFormat.TextPlain?
[error]       .in(query[AuthorizationCode]("code"))
[error]     

We can see that we are missing some codecs. Why does it happen?

When tapir processes headers, path pieces or query parameters, it can only reason in terms of plain text strings. To let it know how to operate on our value classes, we need to provide relevant codecs in implicit scope. It’s best to provide those in companion objects of our value classes.

This is how we implement Tapir TextPlain codec in Tapir:

final case class State(value: String) extends AnyVal

object State {
  implicit val endpointCodec: Codec[String, State, TextPlain] =
    Codec.string.map(State(_))(_.value)
}

As we can see, Codec.string provides a map method, that takes two functions. First one takes a String argument and converts it into our value class. The second one does the opposite, changing our value class to String. Alternatively we can use Codec.stringCodec(State(_)) that does the same trick.

With this knowledge, we can adapt our implementation. Our final endpoints implementation looks like this:

package example

import sttp.tapir._
import sttp.tapir.CodecFormat.TextPlain


object OAuthEndpoints {

  val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any] =
    endpoint
      .get
      .in("api" / "login-redirect")
      .out(header[RedirectUrl]("Location"))
      .out(statusCode(sttp.model.StatusCode.SeeOther))

  val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any] =
    endpoint
      .get
      .in("api" / "post-login")
      .in(query[AuthorizationCode]("code"))
      .in(query[State]("state"))
      .out(stringBody)

  final case class AuthorizationCode(value: String) extends AnyVal

  object AuthorizationCode {
    implicit val endpointCodec: Codec[String, AuthorizationCode, TextPlain] =
      Codec.string.map(AuthorizationCode(_))(_.value)
  }

  final case class State(value: String) extends AnyVal

  object State {
    implicit val endpointCodec: Codec[String, State, TextPlain] =
      Codec.string.map(State(_))(_.value)
  }

  final case class RedirectUrl(value: String) extends AnyVal

  object RedirectUrl {
    implicit val endpointCodec: Codec[String, RedirectUrl, TextPlain] =
      Codec.string.map(RedirectUrl(_))(_.value)
  }

}

This version can also be found in the demo repository in this particular commit.

Router

Now that our endpoints are ready, we need to implement two methods the same way we did in part one of this article. Our methods can be gathered in a trait we’d call a Router like this:

import cats.effect.IO
import OAuthEndpoints._

trait OAuth2Router {
  def loginRedirect: IO[RedirectUrl]
  def handleLogin(code: AuthorizationCode, state: State): IO[String]
}

loginRedirect corresponds with val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any] and handleLogin with val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any].

Now that we have decided how to shape our router, let’s implement it. Let’s create a companion object with instance method:

import sttp.model.Uri 

object OAuth2Router {

  def instance(
    authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO]
  ): OAuth2Router = new OAuth2Router {

    override def loginRedirect: IO[RedirectUrl] = ???

    override def handleLogin(code: AuthorizationCode, state: State): IO[String] = ???

  }

}  

Login redirect

Mind that our instance takes a parameter - implementation of AuthorizationCodeProvider. We’ll use it for our implementation. Let’s start with loginRedirect.

override def loginRedirect: IO[RedirectUrl] = {
  val uri = authorizationCodeProvider.loginLink()
  val redirectUrl = RedirectUrl(uri.toString())
  IO.pure(redirectUrl)
}

We are performing a pure computation - authorizationCodeProvider generates a link, we wrap it in desired data structure and then wrap it with IO monad to satisfy the interface. We do wrap in IO already as Http4sServerInterpreter.toRoutes expects the result to be wrapped in IO anyway.

Login handling

Now to the more complex part. The implementation for handleLogin should:

  • Use the authorization code to retrieve user token
  • Use token to request user info from Github API
  • Return user name from received response

Before continuing with handleLogin, let’s have a second to think about the Github API. To receive authenticated user details we’ll use https://api.github.com/user. The documentation for this endpoint describes the full data structure, but to make things easier we’ll only use two fields: name and login. Here’s how we’d model this subset of API response:

object Github {
  final case class UserInfo(
    login: String,
    name: String
  )
}

We have the model, we still need to describe our API behavior. Let’s once again (as for OAuthRouter) follow programming to an interface.

In this case we only need one endpoint, so our trait would have single method:

trait Github {
  def userInfo(accessToken: Secret[String]): IO[Github.UserInfo]
}  

The Secret type comes from sttp-oauth2, we import it with import com.ocadotechnology.sttp.oauth2.Secret. It’s useful for working with data we don’t want to leak into logs. It provides custom toString method, that returns obfuscated content instead of the original one.

We’ll leave the implementation for now. Let’s get back to OAuthRouter and implement handleLogin using this API.

The first thing we need to change is the instance method’s signature, as it now takes Github instance as another parameter:

def instance(
  authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO],
  github: Github
)  

Now that this API is available in scope, we can finally implement handleLogin:

override def handleLogin(code: AuthorizationCode, state: State): IO[String] = 
  for {
    token    <- authorizationCodeProvider
                  .authCodeToToken[OAuth2TokenResponse](code.value)
    userInfo <- github.userInfo(token.accessToken)
  } yield s"Logged in as $userInfo"  

Finally our complete implementation of OAuthRouter looks like this:

package example

import cats.effect.IO
import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider
import com.ocadotechnology.sttp.oauth2.OAuth2TokenResponse
import sttp.model.Uri

import OAuthEndpoints._

trait OAuth2Router {
  def loginRedirect: IO[RedirectUrl]
  def handleLogin(code: AuthorizationCode, state: State): IO[String]
}

object OAuth2Router {

  def instance(
    authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO],
    github: Github
  ): OAuth2Router = new OAuth2Router {

    override def loginRedirect: IO[RedirectUrl] = {
      val uri = authorizationCodeProvider.loginLink()
      val redirectUrl = RedirectUrl(uri.toString())
      IO.pure(redirectUrl)
    }

    override def handleLogin(code: AuthorizationCode, state: State): IO[String] =
      for {
        token    <- authorizationCodeProvider
                      .authCodeToToken[OAuth2TokenResponse](code.value)
        userInfo <- github.userInfo(token.accessToken)
      } yield s"Logged in as $userInfo"

  }

}

Github API implementation

Our application is not complete without Github implementation. To do that we need HTTP client, we’ll use sttp library.

Let’s plan our work, here’s what we need our API implementation to do:

  • Define request matching the user info endpoint documentation. Remember about
    • Using correct request method
    • Attaching authorization header
  • Send the response using provided SttpBackend instance
  • Decode the received JSON into Github.UserInfo case class

Yet another new term - decoding JSON into case classes. This functionality is provided by circe. We won’t be going to deep into the details, as this is a subject for another article. For our use case it’s enough to rely on automatic codec derivation. This means that circe, at compile time, will generate implementations of Codec for every case class in the file where derivation is imported. In case it’s not enough for your use case, refer to the documentation for writing custom codecs.

Providing circe codecs is enough in our case, because sttp provides circe integration.

Now that we have the theory explained, let’s get back to coding. To make the automatic derivation happen, we need a handful of imports first:

import sttp.client3._          // sttp API 
import sttp.client3.circe._    // sttp circe integration
import io.circe.generic.auto._ // automatic derivation of circe codecs for case classes

Now in the body of Github object, let’s implement instance according to our plan.

val baseUri = uri"https://api.github.com/"

def instance(backend: SttpBackend[IO, Any]) = new Github {

  override def userInfo(accessToken: Secret[String]): IO[UserInfo] = {
    val header = Header("Authorization", s"Bearer ${accessToken.value}")
    basicRequest
      .get(baseUri.withPath("user"))
      .headers(header)
      .response(asJson[UserInfo])
      .send(backend)
      .map(_.body)
      .rethrow

  }

}

We have started with defining baseUri for Github API. Then the instance method, taking SttpBackend (parameterized by effect type IO and no particular capabilities - thus Any).

User info follows sttp request definition. We first define header content for authorization. Then next four lines describe GET request to https://api.github.com/user endpoint, attaching our header. response(asJson[UserInfo]) tells us a lot - we declare the that we expect the endpoint to return Content-Type: application/json that can be deserialized to UserInfo case class. Quite expressive huh?

Then comes the send(backend) method. It uses the backend to perform the request. At this point we are working with IO[Response] type. Then we map the resulting IO to contain just the body - we don’t care about other things like response headers.

The last thing is rethrow (available through cats.implicits._). It basically converts IO[Either[ResponseException, UserInfo]] to IO[UserInfo]. In case the inner Either was Left, the IO will be failed.

To make sure everything compiles, make sure to include following imports at the top of your file.

import sttp.model.Header
import cats.implicits._

Our final contents of Github.scala

package example

import cats.effect.IO
import cats.implicits._
import com.ocadotechnology.sttp.oauth2.Secret
import io.circe.generic.auto._
import sttp.client3._
import sttp.client3.circe._
import sttp.model.Header

trait Github {
  def userInfo(accessToken: Secret[String]): IO[Github.UserInfo]
}

object Github {

  final case class UserInfo(
    login: String,
    name: String
  )

  val baseUri = uri"https://api.github.com/"

  def instance(backend: SttpBackend[IO, Any]) = new Github {
    override def userInfo(accessToken: Secret[String]): IO[UserInfo] = {
      val header = Header("Authorization", s"Bearer ${accessToken.value}")
      basicRequest
        .get(baseUri.withPath("user"))
        .headers(header)
        .response(asJson[UserInfo])
        .send(backend)
        .map(_.body)
        .rethrow
    }
  }
}

This part is also summarized in this commit in the demo repository.

Connecting the dots

At this point we have all the building blocks in place. Let’s make our app use the code we’ve just written. To make our app work, we’ll need to:

  • Create an instance of SttpBackend - we’ll use cats-effect backend for that
  • Instantiate AuthorizationCodeProvider with the grant, configured with our github application details (obtained in github app creation process)
  • Make an instance of our Github API
  • Create OAuth2Router, create HttpRoutes by connecting the router logic with endpoint definitions
  • Expose our routes in HTTP server

Before we get started, let’s make one single fix. Our main application is still called Hello.scala. Let’s rename the file to Main.scala and rename the object Hello to object Main.

SttpBackend

In the previous article we have already defined all necessary dependencies. Following import: "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats-ce2" provides the backend implementation for us. We’ll use cats-effect backend, as we have built our app on top of cats effect stack. There are plenty more backends available, depending on your use case. Please refer to sttp documentation to find them.

We’ll follow the documentation for cats-effect backend. In our Main.scala file, we need to add an import: import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend. With it, instantiating the backend is as simple as calling AsyncHttpClientCatsBackend[IO](). This constructor returns IO[SttpBackend[IO, Any]], while our interfaces require SttpBackend[IO, Any]. To unwrap it, we need to flatMap the outer IO instance.

To start with, we could go with something like this:

override def run(args: List[String]): IO[ExitCode] = 
  AsyncHttpClientCatsBackend[IO]().flatMap { sttpBackend =>
    // Some logic for creating AuthorizationCodeProvider and Github instances
    serverResource(helloWorldRoutes)(executionContext).use(_ => IO.never)
  }.as(ExitCode.Success)

This approach doesn’t scale well in case we have more IO wrapped values to unwrap. We can use the for comprehension to make it look nicer and scale better.

It would look somewhat like this:

override def run(args: List[String]): IO[ExitCode] = for {
  sttpBackend <- AsyncHttpClientCatsBackend[IO]()
  appConfig <- ???
  authorizationCodeProvider = ???
  github = ???
  oAuth2Router = ???
  routes = ???
  _           <- serverResource(helloWorldRoutes)(executionContext)
                    .use(_ => IO.never)
} yield ExitCode.Success

Now that we have the skeleton prepared, let’s fill the gaps. We have left helloWorldRoutes in the server resource initialization just to make the example compile. We’ll replace it with routes later on.

You probably noticed the appConfig part. Our application needs to read application ID and secret, we’d also like to be able to configure listening host and port, thus let’s write a simple configuration reader.

Configuration

As discussed above, our application could use some configuration. There are two areas we definitely would like to configure. First of those is the http server host details. It’s nothing sophisticated, we only need a host and port, something we can model like this:

final case class Server(
  host: String,
  port: Int
)  

Then there’s the second part, the OAuth2 application details. On the github side, we obtain appId and appSecret, and to let sttp-oauth2 know which identity provider to contact, we could also need some providerBaseUrl. Overall this is how it could look like:

final case class OAuth2(
  providerBaseUrl: Uri,
  appId: String,
  appSecret: String
)  

Mind the usage of Uri here, it’s sttp.model.Uri, it makes sure we are not providing non-compliant string.

Overall this is how our Config.scala file looks like

package example

import sttp.model.Uri

final case class Config(
  oauth2: Config.OAuth2,
  server: Config.Server
)

object Config {

  final case class Server(
    host: String,
    port: Int
  )

  final case class OAuth2(
    providerBaseUrl: Uri,
    appId: String,
    appSecret: String
  )

}

Now that we have modeled the structure, let’s read the configuration from somewhere. To make things easier, we’ll only read the necessary details from environment variables.

We’ll start by creating ConfigReader.scala file like this:

package example

import cats.effect.IO

object ConfigReader {
  
  def read: IO[Config] = ???
}  

If we want to read data from env variables, we’ll need some logic for that:

private def readFromEnv(variableName: String): IO[String] = 
  IO.delay(sys.env(variableName))  

This uses scala’s builtin sys package, that provides env map. Accessing a map like that would normally make a risk of throwing an excaption, so using the IO.defer makes perfect sense here, quoting the documentation:

Suspends a synchronous side effect in IO.

Any exceptions thrown by the effect will be caught and sequenced into the IO.

Now let’s use this method to create Config.OAuth2 instance:

private def readOAuth2Config: IO[Config.OAuth2] = for {
  id     <- readFromEnv(appIdEnvVariable)
  secret <- readFromEnv(appSecretEnvVariable)
  url = Uri.unsafeParse("https://github.com/")
} yield Config.OAuth2(url, id, secret)

You can notice we are using unsafeParse. If we were to parse arbitrary string from some input, we’d use Uri.parse and handle the left hand side of the resulting Either. In this case we are sure this is a valid string, as it’s hard coded, and we just want to simplify the implementation.

To make things even easier, let’s use static server configuration for the beginning

private val serverConfig = Config.Server("localhost", 8080)

You can rewrite it in terms of readFromEnv on your own as you please.

Gathering it all together, we end up with following contents of ConfigReader.scala:

package example

import cats.effect.IO
import cats.implicits._
import sttp.model.Uri

object ConfigReader {

  val appIdEnvVariable = "APP_ID"
  val appSecretEnvVariable = "APP_SECRET"

  private def readFromEnv(variableName: String): IO[String] =
    IO.delay(sys.env(variableName))

  private def readOAuth2Config: IO[Config.OAuth2] = for {
    id     <- readFromEnv(appIdEnvVariable)
    secret <- readFromEnv(appSecretEnvVariable)
    url = Uri.unsafeParse("https://github.com/")
  } yield Config.OAuth2(url, id, secret)

  private val serverConfig = Config.Server("localhost", 8080)

  def read: IO[Config] = for {
    oAuth2Config <- readOAuth2Config
  } yield Config(
    oAuth2Config,
    serverConfig
  )

}

Our simple and elegant reader is ready. Let’s go straight back to Main.scala and use it. There are two changes to be made:

  • config reader should be used instead of ??? in run method
  • serverResource method should use provided server config for host and port

The updated code looks like this:

def serverResource(httpConfig: Config.Server, routes: HttpRoutes[IO])(ec: ExecutionContext) =
  BlazeServerBuilder[IO](ec)
    .bindHttp(httpConfig.port, httpConfig.host)
    .withHttpApp(Router("/" -> routes).orNotFound)
    .resource

override def run(args: List[String]): IO[ExitCode] = for {
  sttpBackend <- AsyncHttpClientCatsBackend[IO]()
  appConfig   <- ConfigReader.read
  authorizationCodeProvider = ???
  github = ???
  oAuth2Router = ???
  routes = ???
  _           <- serverResource(appConfig.server, helloWorldRoutes)(executionContext).use(_ => IO.never)
} yield ExitCode.Success

AuthorizationCodeProvider

The AuthorizationCodeProvider provided by import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider has 2 methods for creating the provider: uriInstance and refinedInstance. We’ll use AuthorizationCodeProvider.uriInstance as it uses sttp.model.Uri so it matches our model. Mind that this method takes the sttp backend implicitly, in the second parameter list.

In our run method we already have SttpBackend and Config instances, so let’s define a method that uses those to create AuthorizationCodeProvider:

def authorizationCodeProviderInstance(
  config: Config
)(
  implicit backend: SttpBackend[IO, Any]
): AuthorizationCodeProvider[Uri, IO] =
  AuthorizationCodeProvider.uriInstance[IO](
    baseUrl = config.oauth2.providerBaseUrl,
    redirectUri = Uri.unsafeParse(s"http://${config.server.host}:${config.server.port}/api/post-login"),
    clientId = config.oauth2.appId,
    clientSecret = Secret(config.oauth2.appSecret),
    pathsConfig = AuthorizationCodeProvider.Config.GitHub
  )  

To make it work we could use a handful of imports:

import sttp.client3.SttpBackend
import sttp.model.Uri
import com.ocadotechnology.sttp.oauth2.Secret

With that defined, let’s update our run method, replacing ??? with proper implementation:

override def run(args: List[String]): IO[ExitCode] = for {
  sttpBackend <- AsyncHttpClientCatsBackend[IO]()
  appConfig   <- ConfigReader.read
  authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
  github = ???
  oAuth2Router = ???
  routes = ???
  _           <- serverResource(appConfig.server, helloWorldRoutes)(executionContext)
                    .use(_ => IO.never)
} yield ExitCode.Success  

Exposing the routes

We’re almost done, with no further hesitation we can fill out two more gaps by simply calling instance methods on Github and OAuth2Router, as we already have all the required parameters:

override def run(args: List[String]): IO[ExitCode] = for {
  sttpBackend <- AsyncHttpClientCatsBackend[IO]()
  appConfig   <- ConfigReader.read
  authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
  github = Github.instance(sttpBackend)
  oAuth2Router = OAuth2Router.instance(authorizationCodeProvider, github)
  routes = ???
  _           <- serverResource(appConfig.server, helloWorldRoutes)(executionContext)
                    .use(_ => IO.never)
} yield ExitCode.Success  

The only thing left here is to implement the routes = ??? and use it instead of helloWorldRoutes.

To start with, let’s change following import:

import sttp.tapir.server.http4s.Http4sServerInterpreter  

to this one:

import sttp.tapir.server.http4s.Http4sServerInterpreter.toRoutes  

Now we’ll implement our routes generator. Fasten your seat belt as this one might be tough.

def appRoutes(oAuth2Router: OAuth2Router): HttpRoutes[IO] = 
  List(
    toRoutes(helloWorld)(endpointLogic _),
    toRoutes(OAuthEndpoints.loginRedirect)(
      _ => oAuth2Router.loginRedirect.map(_.asRight[Unit])
    ),
    toRoutes(OAuthEndpoints.postLogin)(
      (oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))
    )
  ).reduceLeft(_ <+> _)  

Now what happened here? Simplification with pseudo-code might be helpful

List(
  httpRoute1,
  httpRoute2,
  httpRoute3
).reduceLeft(_ <+> _)

SemigroupK

Let’s digress for a moment and take baby steps.

To make things easier to understand, let’s imagine we have a list of Ints we want to combine. We could do it like this:

scala> List(1,2,3).reduceLeft(_ + _)
val res1: Int = 6

In Cats we could go with more generic approach using Semigroup:

import cats.implicits._

scala> import cats.Semigroup
import cats.Semigroup

scala> List(1,2,3).reduceLeft(Semigroup[Int].combine)
val res2: Int = 6

Simply speaking, Semigroup[A] over a type A means that two values can be combined into another value of type A. Please refer Cats documentation and Herding cats - Semigroup - those are the best explanations in my opinion.

Now that we have an intuition about Semigroup, how about combining a sequence of lists like this: Seq(List(1,2), List(3,4))?

We cannot use Semigroup[A] here, as it’s defined for simple type A. We need something like Semigroup[F[_]] that would work over generic types. Such thing exists and it’s called SeimgroupK[F[_]]. Again, I won’t go into details as both cats documentation on SemigroupK and Herding cats - SemigroupK explain it perfectly already.

Here’s how we’d combine sequence of lists using SemigroupK:

scala> Seq(List(1,2), List(3,4)).reduceLeft(_ <+> _)
val res3: List[Int] = List(1, 2, 3, 4)

The <+> here is an alias for combineK method in SemigroupK. End of digression.


Http4s provides an instance of SemigroupK for HttpRoute (please refer to http4s documentation), thus we can use <+> to combine routes.

Another complicated parts in the example above are

_ => oAuth2Router.loginRedirect.map(_.asRight[Unit])

and

(oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))

If you recall the first part of this article, the server interpreter expects a function that takes a tuple (I1, I2,...IN) and converts it to Either[Error, Result] wrapped in effect. With this in mind, considering the endpoint definitions we have:

val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any]

So the interpreter expects a method of a signature:

Unit => IO[Either[Unit, RedirectUrl]]

And for postLogin:

val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any]  

we need:

((AuthorizationCode, State)) => IO[Either[Unit, String]]

The code complication is there only to satisfy the expected types.

Launching the app 🚀

Our final Main.scala file looks like this:

package example

import cats.effect._
import sttp.tapir._

import sttp.tapir.server.http4s.Http4sServerInterpreter.toRoutes
import org.http4s.HttpRoutes

import cats.syntax.all._

import scala.concurrent.ExecutionContext
import org.http4s.server.blaze.BlazeServerBuilder
import org.http4s.server.Router
import org.http4s.syntax.kleisli._

import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend
import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider
import sttp.client3.SttpBackend
import sttp.model.Uri
import com.ocadotechnology.sttp.oauth2.Secret

object Main extends IOApp {

  val helloWorld: Endpoint[String, Unit, String, Any] =
    endpoint.get.in("hello").in(query[String]("name")).out(stringBody)

  def endpointLogic(name: String) = IO(s"Hello, $name!".asRight[Unit])

  val helloWorldRoutes: HttpRoutes[IO] =
    toRoutes(helloWorld)(endpointLogic _)

  def serverResource(httpConfig: Config.Server, routes: HttpRoutes[IO])(ec: ExecutionContext) =
    BlazeServerBuilder[IO](ec)
      .bindHttp(httpConfig.port, httpConfig.host)
      .withHttpApp(Router("/" -> routes).orNotFound)
      .resource

  def authorizationCodeProviderInstance(
    config: Config
  )(
    implicit backend: SttpBackend[IO, Any]
  ): AuthorizationCodeProvider[Uri, IO] =
    AuthorizationCodeProvider.uriInstance[IO](
      baseUrl = config.oauth2.providerBaseUrl,
      redirectUri = Uri.unsafeParse(s"http://${config.server.host}:${config.server.port}/api/post-login"),
      clientId = config.oauth2.appId,
      clientSecret = Secret(config.oauth2.appSecret),
      pathsConfig = AuthorizationCodeProvider.Config.GitHub
    )

  def appRoutes(oAuth2Router: OAuth2Router): HttpRoutes[IO] =
    List(
      toRoutes(helloWorld)(endpointLogic _),
      toRoutes(OAuthEndpoints.loginRedirect)(_ => oAuth2Router.loginRedirect.map(_.asRight[Unit])),
      toRoutes(OAuthEndpoints.postLogin)(
        (oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))
      )
    ).reduceLeft(_ <+> _)

  override def run(args: List[String]): IO[ExitCode] = for {
    sttpBackend <- AsyncHttpClientCatsBackend[IO]()
    appConfig   <- ConfigReader.read
    authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
    github = Github.instance(sttpBackend)
    oAuth2Router = OAuth2Router.instance(authorizationCodeProvider, github)
    routes = appRoutes(oAuth2Router)
    _           <- serverResource(appConfig.server, routes)(executionContext).use(_ => IO.never)
  } yield ExitCode.Success

}

It had a little clean up as val helloWorldRoutes was no longer necessary. The code is also available in this commit on Github.

To launch the application, run following command in the root of your repository:

APP_ID="..." APP_SECRET="..." sbt

Fill the dots with the details from app registration process. Type run when prompted by the sbt console.

In your browser, navigate to https://localhost:8080/api/login-redirect and you should be redirected to login screen:

login screen

After logging in, you’ll be redirected to https://localhost:8080/api/post-login and you should see something like this:

post login screen

Summary

In this part we have learned how to scale our application with new functionalities. We have managed to use libraries like sttp, circe and sttp-auth2 to build web application and enable login with Github.

Hope you enjoyed this part, feel free to contact me in case of any questions and stay tuned, there’s more to come!