Menu

Official website

JWT token validation in Ktor with Client Credentials Flow


07 Jun 2023

min read

At Lunatech we build many tools that facilitate internal processes like keeping track of employees' vacations, share internal events and workshops and even one just for the days Lunatech offers us lunch ;) So, it’s only natural that we end up creating integrations between these applications.

The goal of this post is to explore the scenario where one application authenticates with another by using the Client Credentials Flow, in Kotlin, Ktor and Keycloak. To achieve this, we are going to build a simple personal luna-vacation-bot that will inform you daily, via a Slack message, whether the present day is a vacation day or not. For this, we will need to implement a dummy luna-vacation-api as well.

Diagram
Figure 1. Happy path diagram

In the above diagram, we can see that the starting point of our system will be the luna-vacation-bot. It asks the luna-vacation-api whether the present day is a working day or not, but, for that request to be successful, first it needs to request a JWT token from Keycloak. Once it receives the token then it can send the request to the luna-vacation-api and, based on the response of that request, it can inform the user via a Slack message. If luna-vacation-bot fails to receive a token from Keycloak or, if luna-vacation-api does not respond successfully, we can also send an error message via Slack, informing the user of the issue.

How to setup Keycloak?

First, we need a running instance of Keycloak. Recently, Lunatech deployed its own Keycloak instance for the Identity and Access Management of applications, but for this example I’m going to run a local instance of Keycloak, instead. We can easily run one using Docker. The admin console can be found at localhost:8080 with both username and password being admin.

Create a realm

On top of the default master realm let’s create our own realm called lunarealm.

Create Realm
Figure 2. Creating a new Realm

Create a client

Let’s create the client luna-vacation-bot which is the one that is going to need to authenticate with the luna-vacation-api and use the API:

Create client
Figure 3. Creating a new client

After setting the client id, name and description, click Next for the capability config section and turn Client authentication and Service accounts roles on and all the other authentication flows off. The client is now configured for the Client Credentials Flow in OAuth2. Click Next again and Save.

Create client
Figure 4. Configuring the client capability

In order to increase our client security, we are also creating a new Client Scope called luna-vacation-bot-scope using the default settings:

Create client
Figure 5. Creating a new Client Scope

Once you save the new scope, a couple of configuration tabs will show up: Mappers and Scope. Go to Mappers and choose Configure a new mapper and then choose Audience:

Create client
Figure 6. Creating a new Client Scope Mapper

Give it a name and then set luna-vacation-bot in Included Client Audience and also add it to the tokens:

Create client
Figure 7. Configuring the new Client Scope Mapper

Go back to the details of the luna-vacation-bot client, to Client scopes, Add client scope and choose the new luna-vacation-bot-scope Default scope and add it:

Create client
Figure 8. Adding the new client scope to the client

One more client scope needs a final configuration. Go to the dedicated client scope called luna-vacation-bot-dedicated and click on it. In Mappers choose Add mapper > From predefined mappers and then add audience resolve:

Create client
Figure 9. Adding a predefined mapper to the client scope

Finally, still inside the same luna-vacation-bot-dedicated scope, go to the Scope tag and set Full scope allowed off.

Create client
Figure 10. Modifying the client scope settings

We are all set to start using JWT tokens!

Test the creation of a token

To test if luna-vacation-bot can successfully get a JWT token we also need the client secret. The secret can be found in the Credentials tab:

Client secret
Figure 11. Where to find the client’s secret

Let’s use curl to generate the token:

curl -X post 'http://localhost:8080/realms/lunarealm/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=luna-vacation-bot' \
--data-urlencode 'client_secret=<replace-by-secret>'

The token will look something like:

{"access_token":"eyJhbG...75Eg","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"email luna-vacation-bot_scope profile"}

Verify the token in jwt.io

In jwt.io you can inspect the contents of a token. Copy the access_token string and paste it in the Encoded box. I’m not going into details about the format and content of the token, you can read a nice introduction about it in jwt.io site as well.

Client secret
Figure 12. Some of the token payload details

We can confirm that the issuer (iss) of the token is lunarealm and that the audience (aud) and the authorized party (azp) are luna-vacation-bot.

Create the luna-vacation-api

We are using Kotlin and Ktor to build the API. Following the recent tendency, Ktor provides a project scaffolding generator that adds some sample code and allows adding plugins. Remember to add the Authentication JWT plugin.

Client secret
Figure 13. Generating the luna-vacation-api project

Download and open the project in your IDE of choice. The test dependencies and test folder can be removed. Following the documentation in ktor.io on how to handle JSON web tokens let’s add the following dependencies in build.gradle.kts:

implementation "io.ktor:ktor-server-auth-jwt:2.3.0"
implementation "io.ktor:ktor-server-auth-jwt-jvm:2.3.0"

We can now start adding some code. Let’s start by our API endpoint onvacation that determines that odd day numbers are days off and even days are working days.

fun Application.configureRouting() {
    routing {
        authenticate("auth-jwt") {
            get("/onvacation") {
                val today = LocalDate.now()
                if (today.dayOfMonth % 2 == 0) {
                    call.respondText("Today you have a day off")
                } else {
                    call.respondText("Today you have to work")
                }
            }
        }
    }
}

You can notice that the endpoint already includes an authentication precondition called auth-jwt. This detail is very important, as without it the endpoint would be available to non-authenticated parties.

In fun Application.module() we need to set up how this authentication takes places. In our case we can validate the token realm, issuer, audience and limit access to luna-vacation-bot for authorized parties only. The token signature also needs to be validated and for that we need to provide the url to the JSON Web Keys available in the protocol/openid-connect/certs endpoint.

install(Authentication) {
        jwt("auth-jwt") {
            realm = "lunarealm"
            verifier(
                UrlJwkProvider(URL("http://localhost:8080/realms/lunarealm/protocol/openid-connect/certs")),
                "http://localhost:8080/realms/lunarealm",
            ) {
                withAudience("luna-vacation-bot")
                withClaim("azp", "luna-vacation-bot")
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
            }
            validate { credential ->
                validateCredential(credential, issuer)
            }
        }
    }

If the authentication fails it returns the challenge, in this case a 401 Unauthorized with an error message.

The validateCredentials function is simply validating that the token has not expired, after all other validations have passed.

fun validateCredential(credential: JWTCredential, issuer: String): JWTPrincipal? {
    if (credential.expiresAt?.after(Date()) == true
    ) {
        return JWTPrincipal(credential.payload)
    }

    return null
}

Test the API

We can test the api using curl. If we secured the endpoint properly, calling the onvacation endpoint without a token should return an error.

$ curl localhost:4040/onvacation
Token is not valid or has expired

As explained in the initial diagram, we need to request a token from Keycloak and send it together with the request. We can use the credentials we have for luna-vacation-bot:

curl -X post 'http://localhost:8080/realms/lunarealm/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=luna-vacation-bot' \
--data-urlencode 'client_secret=<replace-by-secret>'

We grab the access_token part of the response and add it to the request header as a Bearer token:

$ curl 'localhost:4040/onvacation' \
--header 'Authorization: Bearer eyJhbGciOiJ...QBhNiX6w'

And now we get a result:

Today you have to work

Create the luna-vacation-bot

To create the luna-vacation-bot project we are going to use Intellij and create a new Kotlin application, with Gradle and JDK 17 (you can use JDK 8 or higher). After creating the project we can remove the test folder and the test dependencies.

Integrate with luna-vacation-api

In order to query the luna-vacation-api we need an http client. We are going to use the ktor-client library by adding the necessary dependencies to build.gradle.kts:

implementation("io.ktor:ktor-client-core:2.3.1")
implementation("io.ktor:ktor-client-cio:2.3.1")
implementation("io.ktor:ktor-client-serialization:2.3.1")
implementation("io.ktor:ktor-client-content-negotiation:2.3.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.1")

When creating the http client we need to add the json ContentNegotiation and the flag ignoreUnknownKeys for the response deserialization, to ignore the json fields that we are not interested in.

    val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true
                },
            )
        }
    }

Now that the http client is properly setup we will start by retrieving a JWT token from Keycloak:

@Serializable
data class BotToken(val access_token: String)

val token = client.submitForm(
        url = "http://localhost:8080/realms/lunarealm/protocol/openid-connect/token",
        formParameters = parameters {
            append("grant_type", "client_credentials")
            append("client_id", "luna-vacation-bot")
            append("client_secret", "<replace by client_secret>")
        },
    ).body<BotToken>().access_token

and then we can finally call the luna-vacation-api:

val response = client.get("http://localhost:5050/imionvacation") {
        bearerAuth(token)
    }.bodyAsText()

And what do we do with the response now? We send it to Slack using a Slack App. So let’s do that next.

Create a Slack app

You can create your own workspace at slack.com. Once you have it, you can create a new App. Choose Create New App and then From scratch:

Diagram
Figure 14. Creating a new Slack App

I named the app ImIOnVacation and added it to my personal workspace:

Diagram
Figure 15. Configuring the Slack App workspace

To finalize the App creation you need to set some permissions:

Diagram
Figure 16. Configuring the Slack App permissions

In Basic Information you can personalize the appearance off the app, but for now let’s jump to OAuth & Permission and, from there, scroll down to the Scopes section. In User Token Scopes add the scopes chat:write:

Diagram
Figure 17. Configuring the User Token Scopes

Then scroll up and click Install to Workspace. That will take us to a permissions screen: click Allow. We can see that a User OAuth Token was created as well.

Diagram
Figure 18. Slack App User OAuth token

We will need this User OAuth Token to be able to send messages to Slack via this App.

Put it all together

We can now send that luna-vacation-api response to a Slack channel. The channel I’ll use is the chat with myself. On Slack, channel IDs can be seen on the channel or chat details, at the very bottom.

For the communication with Slack we will use Slack’s Bolt SDK, starting by adding the necessary dependencies:

implementation("com.slack.api:bolt:1.29.2")
implementation("com.slack.api:bolt-servlet:1.29.2")
implementation("com.slack.api:bolt-jetty:1.29.2")
implementation("org.slf4j:slf4j-simple:1.7.36")

Now let’s compose our message. The function getOnVacation is wrapping the luna-vacation-api request.

val text = getOnVacation(client)
val response = slack.methods("xoxp-...").chatPostMessage { req: ChatPostMessageRequestBuilder ->
    req
        .channel("<replace by my channel>")
        .text(text)
}

Make sure luna-vacation-api is still running and now let’s run luna-vacation-bot. If we check Slack we should see a message:

Diagram
Figure 19. Slack message

The complete source code for the luna-vacation-api and luna-vacation-bot are available on GitHub.

Next steps

We can transform this kind of application integration in a real automation by running it periodically. A cron job allows to achieve this goal and the quartz-scheduler library integrates well with Kotlin. The cron job can be configured as often was one wishes like several time a day or once a month, for example.

In this blog post we used a dummy API. For a real use case we could replace it with an API that provides data reports, for example. Imagine yourself surprising your manager with some automated reports delivered to their Slack or email address!

expand_less