Securing your APIs with Oauth 2.0
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.
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 withsetToken
. In this case is not really managing the token, it will just request it once and set it in our shared statetoken
. 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.
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:
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!