JWT token validation in Ktor with Client Credentials Flow
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.
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 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:
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.
In order to increase our client security, we are also creating a new Client Scope
called luna-vacation-bot-scope
using the default settings:
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
:
Give it a name and then set luna-vacation-bot
in Included Client Audience
and also add it to the tokens:
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:
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
:
Finally, still inside the same luna-vacation-bot-dedicated
scope, go to the Scope
tag and set Full scope allowed
off.
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:
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.
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.
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()
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
:
I named the app ImIOnVacation
and added it to my personal workspace:
To finalize the App creation you need to set some 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
:
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.
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:
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!