Securing your APIs with Oauth 2.0

Samuel Fernandez Munoz
5 min readJun 16, 2024

--

In some cases two systems will communicate with each other. For security reasons the system (API) providing the service will require authentication, a way for this API to know that the incoming requests are coming from a trusted client.

We will build an client — server application in Scala 3 using ZIO HTTP. The requests in the server will be authenticated with Oauth 2.0 using Okta authentication provider.

Disclaimer: the source code is a working demo of a client creating authenticated calls to a server that requires requests to be authenticated. But this is not production ready.

System architecture

We will start with creating our SBT project:

» mkdir okta-app 
» cd okta-app
» echo "sbt.version=1.8.2" > project/build.properties

Next is creating build.sbt :

ThisBuild / scalaVersion := "3.3.1"
ThisBuild / organization := "oktaapp"
ThisBuild / version := "0.1.0"

lazy val client = project.in(file("client"))
.settings(
Compile / run / mainClass := Some("oktaapp.Main"),
libraryDependencies += "dev.zio" %% "zio-http" % "3.0.0-RC8",
)

lazy val server = project.in(file("server"))
.settings(
Compile / run / mainClass := Some("oktaapp.Main"),
libraryDependencies += "dev.zio" %% "zio-http" % "3.0.0-RC8",
libraryDependencies += "com.okta.jwt" % "okta-jwt-verifier" % "0.5.8",
libraryDependencies += "com.okta.jwt" % "okta-jwt-verifier-impl" % "0.5.8" % Runtime,
)

We have two modules, one for the client and another for the server. Only dependencies are ZIO-HTTP and okta-jwt-verifier that will help us checking the tokens in the requests coming towards the server.

Following is the client ./client/src/main/scala/oktaapp/Main.scala:

package oktaapp

import zio.*
import zio.http.*
import zio.http.codec.*
import zio.http.endpoint.*
import zio.http.ZClient.Config
import zio.http.netty.NettyConfig
import zio.schema.*
import zio.schema.codec.JsonCodec.*

object HttpClient:
val defaultConfig = ZLayer.make[Config with DnsResolver with NettyConfig](
ZLayer.succeed(ZClient.Config.default),
ZLayer.succeed(NettyConfig.default),
DnsResolver.default
)
val live: ULayer[Client] = defaultConfig >>> Client.live.orDie
end HttpClient

object TokenManagement:
case class OktaResponse(token_type: String, expires_in: Int, access_token: String, scope: String)

object OktaResponse:
given Schema[OktaResponse] = DeriveSchema.gen[OktaResponse]
end OktaResponse

def setToken(token: Ref[String]): ZIO[Client, Throwable, Unit] =
val clientId = "0oafalmfv8b6OxpKz697"
val clientSecret = "<secret>" // You need your own secret here
val tokenUrl = URL.decode("https://trial-6637512.okta.com/oauth2/default/v1/token").toOption.get
val body = Body.fromString("grant_type=client_credentials&scope=custom_scope")
val headers = Headers(
Header.Accept(MediaType.application.json),
Header.ContentType(MediaType.application.`x-www-form-urlencoded`),
Header.CacheControl.NoCache,
Header.Authorization.Basic(clientId, clientSecret)
)
val oktaRequest = Request.post(tokenUrl, body).addHeaders(headers)

ZIO.serviceWithZIO[Client] { client =>
ZIO.scoped {
client.request(oktaRequest).flatMap(_.body.to[OktaResponse])
}.flatMap(oktaResponse => token.set(oktaResponse.access_token))
}
end setToken
end TokenManagement

object Main extends ZIOAppDefault:
val port = 9001
val serverUrl = "http://localhost:9000/data"
val token: Ref[String] = Unsafe.unsafe(implicit unsafe => Ref.unsafe.make(""))

val dataEndpoint = Endpoint(Method.GET / "data").out[String].implement {
def dataRequest() =
token.get.map(t => Request.get(serverUrl).addHeader(Header.Authorization.Bearer(t)))

Handler.fromZIO {
ZIO.serviceWithZIO[Client] { client =>
ZIO.scoped {
dataRequest()
.flatMap(client.request)
.flatMap(_.body.asString)
.catchAll(e => ZIO.succeed(s"Failed client call: $e"))
}
}
}.provideLayer(HttpClient.live)
}

override val run: ZIO[Any, Throwable, Nothing] =
TokenManagement.setToken(token).provide(HttpClient.live) *>
Server.serve(Routes(dataEndpoint)).provide(Server.defaultWithPort(port))

end Main

The client is divided in three parts:

  • HttpClient : use to fire http calls to the server and the okta authentication service.
  • TokenManagement : use to manage the authentication token with setToken . In this case is not really managing the token, it will just request it once and set it in our shared state token . A more production ready code would take care of refreshing the token before its expiration time.
  • Main : Where we run our http application with the data endpoint. This will call the server’s data endpoint using the Okta access token.

Note: Getting your Okta configuration will be explained afterwards.

The last piece will be the server module ./server/src/main/scala/oktaapp/Main.scala :

package oktaapp

import com.okta.jwt.{AccessTokenVerifier, JwtVerifiers}
import zio.*
import zio.http.*
import zio.http.Header.Authorization.*
import zio.http.codec.*
import zio.http.endpoint.*

object Main extends ZIOAppDefault:
val port = 9000

val jwtVerifier: AccessTokenVerifier = JwtVerifiers
.accessTokenVerifierBuilder()
.setIssuer("https://trial-6637512.okta.com/oauth2/default")
.setAudience("api://default")
.build()

val getData = Endpoint(Method.GET / "data").header(HeaderCodec.authorization).out[String].implement {
Handler.fromFunctionZIO[Header.Authorization] {
case Bearer(secret) =>
ZIO.attempt(jwtVerifier.decode(secret.value.asString))
.map(jwt => s"hello my scope: ${jwt.getClaims.get("scp")}")
.catchAll(err => ZIO.succeed(s"Failed server call: $err"))
case other => ZIO.succeed(s"Wrong auth: $other")
}
}

override val run: ZIO[Any, Throwable, Nothing] =
Server.serve(Routes(getData)).provide(Server.defaultWithPort(port))

end Main

Where we create the http application with also a data endpoint. Every request authentication header will be checked by jwtVerifier . Missing step is verifying that the scope is correct, in our case we just return a message with the scope if the token is correct.

Now we can run client and server with the following commands:

» sbt 'client/run'
» sbt 'server/run'

And if everything went fine we can call our client and get a successful response:

» curl localhost:9001/data                                                                           [21:30:38]
"\"hello my scope: [custom_scope]\""

Congratulations, now your server is accepting requests only from authorised clients!

But this won’t work unless you have access to a Okta Authentication server. I will quickly guide you to get a trial account and how to configure the auth server to set up a custom scope.

First head up to Okta and click on: Free trial.

Okta free trial form

Once you get your account set up, head to the admin UI clicking on the admin button in the right top corner. Then go to: Security > API using the menu on the left side, you should get to the default authorization server:

default authentication server

And then you can create a custom scope custom_scope in your default auth server. For that get in the server configuration by clicking in default and create the scope, you should get to a page like this, once created it:

For the client you will need your credentials. You can obtain them by creating an API service integration under Applications menu:

You should be all set up. Thanks for reading and for your feedback!

--

--