Building a Quarkus application with Ory Kratos
Building a new web application is fun and exiting. Building user management and password handling for the eleventh time… not so much. You will open up a huge can of worms for yourself if you try to roll your own security.
This is why “IDaaS” (Identity as a service) providers are on the rise. While Keycloak is the most dominant player in the open source IDaaS market, there is another up and coming alternative. Ory offers an open-source suite of tools for authentication, authorization, and user management. In contrast to the monolithic approach of Keycloak Ory offers lightweight building blocks which seem to be a better fit for the age of Docker.
Here I pick one of Ory’s building blocks, Kratos, and try to integrate it in a Quarkus app written in Kotlin. The outcome is by no means complete, full-featured, production-ready or anything else. It’s a quick sketch for me to get familiar with Kratos.
Spare me the words, show me the code
Here you go: quarkus_kratos_example
Getting up and running
Things I will build:
- User registration
- Login
- Logout
- Some secured resource Things I wont build: Everything else
Since I want to keep it simple I will not use any frontend framework. HTML will be rendered server-side with Quarkus’ home-made templating engine, Qute.
Setting up the project
We will need the following Quarkus extensions:
- resteasy
- kotlin
- resteasy-jackson
- quarkus-rest-cliet
- qute
To set up your project and get the extensions run the following Maven command.
$ mvn io.quarkus:quarkus-maven-plugin:1.12.0.Final:create \
-DprojectGroupId=me.hauke \
-DprojectArtifactId=quarkuskratos \
-Dextensions="resteasy,kotlin,resteasy-jackson,io.quarkus:quarkus-rest-client,io.quarkus:quarkus-resteasy-qute"
After deleting the example files we get the following directory structure:
$ tree .
.
├── README.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── quarkuskratos.iml
└── src
└── main
├── docker
│ ├── Dockerfile.jvm
│ ├── Dockerfile.legacy-jar
│ ├── Dockerfile.native
│ └── Dockerfile.native-distroless
├── kotlin
│ └── me
│ └── hauke
│ └── quarkuskratos
│ └── index
└── resources
├── META-INF
│ └── resources
├── application.properties
└── templates
└── index
13 directories, 10 files
Baby’s first index.html
Lets create a index page and endpoint that will be available at http://127.0.0.1:8080/
We create a simple html template at src/main/resources/templates/index/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<body>
<div>{text}</div>
</body>
</html>
{title}
and {text}
are the value expressions that are evaluated when Qute renders the template.
To get the template rendered we create an IndexEndpoint
:
@Path("") // ①
class IndexEndpoint @Inject constructor(
@ResourcePath("index/index.html") val index: Template // ②
) {
@GET
@Produces(MediaType.TEXT_HTML)
fun index(): TemplateInstance =
index.data(
"title", "My first template", // ③
"text", "Hello 👋🏻"
)
}
①
: Here we define the path where the template should be rendered.
②
: Where can Qute find the template (relative to src/main/resources/templates
)?
③
: Data for value expressions can be filled with pairs of placeholder names and data or by providing an object with matching attributes. In this case something like the following data class would be the correct “view model”:
data class IndexModel(
val title: String,
val text: String
)
Running the project with ./mvnw clean quarkus:dev
and opening http://127.0.0.1:8080/
in our browser renders the template.
Nice.
Setting up Kratos
Kratos in conveniently available via Docker. For development and testing purposes you don’t even need a full database server since it can store everything in sqlite.
For getting started we create the following three files in a kratos
folder in our project root directory:
kratos-quickstart.yml
: Docker compose file for running Kratos and its components (completed file)identity.schema.json
: Schema for user information (completed file)kratos.yml
: Kratos configuration file (completed file)
Now we can run Kratos for the first time:
$ docker-compose -f kratos/kratos-quickstart.yml up --build --force-recreate
Kratos is API-only so we don’t have a fancy GUI for administration. If we would want one, we need to build it ourself.
For now we query Kratos via it’s admin API:
$ curl --request GET --url http://127.0.0.1:4434/health/alive
{"status":"ok"}
Kratos runs two endpoints at once: 127.0.0.1:4433
(public endpoint) and 127.0.0.1:4434
(admin endpoint).
We will use the public or “self service” endpoint, since we want our users to register themself.
Building a registration form
To register a new user in Kratos the following events will happen:
- Browser
GET
s Kratos self service registration url - Kratos creates new registration flow
- Kratos redirects browser to configured Quarkus registration endpoint
- Browser
GET
s Quarkus registration endpoint withflowId
as parameter - Quarkus takes
flowId
andGET
s details of flow from Kratos - Kratos gives flow Details to Quarkus
- Quarkus renders form and delivers form to browser with
csrf_token
_ 8. User fills out form in browser, browserPOST
s form to Kratos - Kratos creates user and executes post registration hook
To start the flow we add the Kratos self service registration url as a link to our index.html
template:
{#include base}
{#title}Welcome{/title}
{#body}
<div>
{text}
</div>
<div>
<a href="http://127.0.0.1:4433/self-service/registration/browser">Sign up for secret recipes</a>
</div>
{/body}
{/include}
There are more changes to the template.
We moved the outer part (<html></html>
and so on) to an base.html
template file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{#insert title}Default Title{/}</title>
</head>
<body>
{#insert body}No body!{/}
</body>
</html>
Qute automatically collects the {#include}
files and builds the template as before.
In the Kratos configuration we defined the registration.ui_url
to be http://127.0.0.1:8080/auth/registration
.
So we need to add the matching endpoint and service to our Quarkus application:
RegistrationEndpoint.kt
:
@Path("/auth/registration")
class RegistrationEndpoint @Inject constructor(
val registrationService: RegistrationService,
) {
@GET
@Produces(MediaType.TEXT_HTML)
fun registration(
@QueryParam("flow") flow: String): TemplateInstance =
registrationService.registration(flow)
}
The endpoint takes the flow
id from the query parameters, calls the service and returns the rendered template.
RegistrationService.kt
:
@ApplicationScoped
class RegistrationService @Inject constructor(
@RestClient val kratosClient: KratosClient,
@ResourcePath("auth/registration.html") val registrationTemplate: Template,
) {
fun registration(flowId: String): TemplateInstance {
val response = kratosClient.getRegistrationFlow(flowId)
val model = RegistrationModel(/*...*/)
return registrationTemplate.data(model)
}
}
(see full file here Github RegistrationService.kt
)
Our service uses a (soon to be build) KratosClient
to get the registration flow details.
These details are used to build the data model for the registrationTemplate
.
The Quarkus application needs a way to ask Kratos for detail of the flow.
Therefore we need the KratosClient
:
@RegisterRestClient(configKey = "kratos")
interface KratosClient {
@GET
@Path("/self-service/registration/flows")
fun getRegistrationFlow(@QueryParam("id") flowId: String): KratosRegistrationResponse
@JsonIgnoreProperties(ignoreUnknown = true)
data class KratosRegistrationResponse(
val id: String,
val type: String,
val messages: List<KratosMessage>?,
val methods: Map<String, KratosMethod>
) {
data class KratosMethod(
val method: String,
val config: KratosMethodConfig
)
data class KratosMethodConfig(
val action: String,
val method: String,
val fields: List<KratosField>
)
data class KratosField(
val name: String,
val type: String,
val required: Boolean = false,
val value: String?,
val messages: List<KratosMessage>?
)
data class KratosMessage(
val id: String,
val text: String,
val type: String
)
}
}
To make the client as simple as possible we use the Microprofile Rest Client.
In application.properties
we define configuration parameters for the client:
kratos/mp-rest/url=http://127.0.0.1:4433/
kratos/mp-rest/scope=javax.inject.Singleton
kratos/mp-rest/hostnameVerifier=io.quarkus.restclient.NoopHostnameVerifier
All that is left to do for registration is to define a auth/registration.html
template:
{#include base}
{#title}Registration{/title}
{#body}
{#with model}
<div>
<p>Welcome to registration</p>
<p>Flow: {flow}</p>
<div>{message}</div>
<div>
<form action="{action}" method="POST">
{#for field in fields}
{#if field.name is 'csrf_token'}
<input type="hidden" name="csrf_token" value="{field.value}">
{#else}
<div>
<label for="{field.id}">{field.name}</label>
<input type="{field.type}" name="{field.name}" id="{field.id}">
{#if field.message}
<div>{field.message}</div>
{/if}
</div>
{/if}
{/for}
<button type="submit">Sign up for cookies</button>
</form>
</div>
<div>
<a href="/">Go home</a>
</div>
</div>
{/with}
{/body}
{/include}
Now we have a link to the registration page at the start page:
And a registration form:
If we try to register a user with a very short password like asdf
Kratos will tell us that this is not a good idea.
Same with lovely ones like Password123!
that other people have used quite often before.
For details on the password policy, see:https://www.ory.sh/kratos/docs/concepts/security/#password-policy
When we register with an acceptable password user is created and Kratos runs the post registration hooks.
We configured them as:
password:
default_browser_return_url: http://127.0.0.1:8080/ # ①
hooks:
- hook: session # ②
So we are redirected (①
) to the main page of our application and we automatically logged in (②
). Nice!
How do we know that we are logged in?
Lets take a look at the cookies:
Here we have the ory_kratos_session
cookie and also a csrf_token
.
Now that we are logged in we can take a look a how we can secure a page by checking for the ory_kratos_session
cookie.
Grandma’s secret cookies
Let’s say I want to share my grandma’s secret cookie recipe, but only with my logged in users and not with any anonymous cookie thief on the internet.
First we create another endpoint RecipeEndpoint
and a recipe.html
Qute template:
RecipeEndpoint.kt
@Path("/recipe")
class RecipeEndpoint @Inject constructor(
@ResourcePath("recipe/recipe.html") val recipe: Template
) {
@GET
@Produces(MediaType.TEXT_HTML)
fun get(): TemplateInstance =
recipe.instance()
}
recipe.html
{#include base}
{#title}My Title{/title}
{#body}
<div>
<p>Hello 👋</p>
<p>The perfect recipe for 🍪s is still in the making. Please check again later.</p>
</div>
<div>
<a href="/">Go home</a>
</div>
{/body}
{/include}
We also create a link from the index.html
template to the recipe page.
index.html
...
<div>
<p>Grandma's super secret cookie recipe</p>
<a href="/recipe">Get it</a>
<p>(You must be logged in)</p>
</div>
...
So far so insecure. Now anybody could access the recipe.
Lets fix that!
Securing an endpoint
Since we don’t want to secure every endpoint with a login we define an annotation that defines which endpoint is secured:
AuthenticationFilter.kt
@NameBinding
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class Secured
For the authentication we define an implementation of a ContainerRequestFilter
that adopts the @Secured
annotation.AuthenticationFilter.kt
@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
class AuthenticationFilter : ContainerRequestFilter {
@Context
lateinit var request: HttpServerRequest
@Inject
@RestClient
lateinit var kratosClient: KratosClient
companion object {
private val LOG = Logger.getLogger(AuthenticationFilter::class.java)
}
override fun filter(context: ContainerRequestContext) {
val cookie: Cookie? = context.cookies["ory_kratos_session"] // ①
if (cookie == null) {
LOG.warn("No cookie found")
throw NotAuthorizedException("please login")
}
LOG.info("Mmmmmh, Cookie: $cookie")
val response = try {
kratosClient.getWhoAmI(cookie) // ②
} catch (e: WebApplicationException) {
LOG.warn(e)
throw NotAuthorizedException("please login")
}
LOG.info(response) // ③
}
}
①
: Here we read the session cookie from the request context.
②
: We need to verify with Kratos if the cookie belongs to a logged in user.
③
: For this testcase we log the whole response with all the user information. Don’t ever do this in real life please.
Now every endpoint that is annotated with @Secured
will be protected by our AuthenticationFilter
.
To ask Kratos about the logged in user we add the following to KratosClient.kt
:
@GET
@Path("/sessions/whoami")
@Throws(WebApplicationException::class)
fun getWhoAmI(@CookieParam("ory_kratos_session") kratosSession: Cookie): String
Kratos will answer with 200
and additional information about the logged in user (username, e-mail verification status, roles, …) if the user is logged in.
If no session is found 401
is returned.
To secure the recipe
endpoint we add the @Secured
annotation.
RecipeEndpoint.kt
...
@Secured
@GET
@Produces(MediaType.TEXT_HTML)
fun get(): TemplateInstance =
...
Since we don’t want to show a blank 401
page to unauthenticated users we add an exception mapper that redirects to the start page:
AuthenticationFilter.kt
@Provider
class PermissionExceptionHandler : ExceptionMapper<NotAuthorizedException> {
override fun toResponse(e: NotAuthorizedException?): Response {
return Response.temporaryRedirect(URI("http://127.0.0.1:8080/")).build()
}
}
Whenever you try to access the super secret endpoint you now need to pass through the AuthenticationFilter
and authenticate yourself with the session cookie. Without this cookie you are simply redirected to the start page.
Logging out
So far we only have one method of logging out: Wait 24 hours until the session cookie expires.
Since we might want to log out earlier we need to add an explicit way to log out.
Logging out is done by simply calling an endpoint in Kratos. So we just add a link in our start page:
<div>
<a href="http://127.0.0.1:4433/self-service/browser/flows/logout">Log out</a>
</div>
A little problem here: We don’t have any CSRF protection on this link. Anybody who places this link anywhere can log out our user. This is a problem that Kratos plans to address.
Logging in
Our users are already logged in when they register and can log out again. But what about logging in?
We just repeat the steps we already did for registering new users. Only the Kratos URLs are different ones.
First we add a link to the Kratos login endpoint to the index page:
index.html
<div>
Please <a href="http://127.0.0.1:4433/self-service/login/browser">login</a>
</div>
The login flow is quite similar to the registration flow:
So we add a login page template:
login.html
{#include base}
{#title}Login{/title}
{#body}
{#with model}
<div>
<p>Welcome to Login</p>
<p>Flow: {flow}</p>
<div>{message}</div>
<div>
<form action="{action}" method="POST">
{#for field in fields}
{#if field.name is 'csrf_token'}
<input type="hidden" name="csrf_token" value="{field.value}">
{#else}
<div>
<label for="{field.id}">{field.name}</label>
<input type="{field.type}" name="{field.name}" id="{field.id}">
{#if field.message}
<div>{field.message}</div>
{/if}
</div>
{/if}
{/for}
<button type="submit">Sign in for cookies</button>
</form>
</div>
<div>
<a href="/">Go home</a>
</div>
</div>
{/with}
{/body}
{/include}
This template is nearly identical to registration.html
and in a more mature implementation I would avoid the duplicate code.
But not today!
The login page is served by the login endpoint Kratos redirects to:
LoginEndpoint.kt
@Path("/auth/login")
class LoginEndpoint @Inject constructor(
val loginService: LoginService
) {
@GET
@Produces(MediaType.TEXT_HTML)
fun login(@QueryParam("flow") flow: String): TemplateInstance =
loginService.login(flow)
}
To get login form data from Kratos we add the matching endpoint to the client:
KratosClient.kt
@GET
@Path("/self-service/login/flows")
fun getLoginFlow(@QueryParam("id") flowId: String): KratosResponse
The response has the same structure as for the registration response so I use the same model .
For consistency I renamed it from KratosRegistrationResponse
to KratosResponse
for consistency.
The endpoint needs a service to assemble the login template from the response of the client:
LoginService.kt
@ApplicationScoped
class LoginService @Inject constructor(
@RestClient val kratosClient: KratosClient,
@ResourcePath("auth/login.html") val loginTemplate: Template,
) {
fun login(flowId: String): TemplateInstance {
val response = kratosClient.getLoginFlow(flowId)
val model = LoginModel(
flow = flowId,
message = response.messages?.toString() ?: "",
action = response.methods["password"]?.config?.action ?: "",
fields = response.methods["password"]?.config?.fields?.map { field ->
LoginModel.LoginFieldModel(
id = field.name,
type = field.type,
value = field.value,
name = field.name,
message = field.messages?.joinToString(";") { it.text }
)
} ?: emptyList()
)
return loginTemplate.data(model)
}
data class LoginModel(
val flow: String,
val message: String = "",
val action: String,
val fields: List<LoginFieldModel> = emptyList()
) {
data class LoginFieldModel(
val id: String,
val type: String,
val value: String?,
val name: String,
val message: String?
)
}
}
Now we can log in with our new form:
And with that last step we are “feature complete” (in a very, very, very lax interpretation of the word) for this prototype.
Summary and next steps
So with just these few steps we created a user registration and login process. While a lot is missing from a fully featured user management we actually got pretty far, especially considering how little work we have done here.
As we have seen it’s quite easy to put Kratos and Quarkus together to create a minimal but working example of securing a page with a login.
Hi, this is a text you should only get, if you are using a screenreader. Thanks for reading my stuff. I would love to provide you a great experience. If you have any feedback on how I can make my stuff more accessible please leave me a note via mail.