Ktor is an open source framework for creating HTTP servers and clients. Its use of non-blocking I/O and asynchronous design (using Kotlin's coroutines) make it an ideal framework for writing servers that remain performant and responsive even when serving many clients at once.
Ktor itself is rather bare-bones, using a plugin model based on Features to extend its functionality. Even rather fundamental server features such as routing, session management and serving static content are optional, and require installing and configuring the corresponding features in your Ktor application to enable them.
While Ktor comes with a standard feature for authentication (i.e. protecting routes by requiring a user to be logged in), it currently lacks a standard feature for authorization, i.e. protecting routes by checking if a logged-in user has the proper privileges to acces them.
Recently I was asked to implement role-based authorization in one of our Ktor-based applications. Implementing it was an interesting journey into the inner workings of Ktor, which is documented in this blog post. If you want to jump straight into the source code you can find a working example here on Github.
Before we talk about authorization, let's setup authentication first. For our demo, we will use form-based login to authenticate the user. After authentication, the user's name and assigned roles are stored in a server-side session.
The user's session is modeled as data class implementing the Principal
marker interface to mark it as the principal in our authentication scheme:
data class UserSession(
val name: String,
val roles: Set<String> = emptySet()
) : Principal
As you can see, we model a user's assigned roles as a set of String
values.
Session management is a feature that needs to be installed separately in Ktor. We configure it to store our session in memory, and assign it a session ID that is stored between requests in a browser cookie:
install(Sessions) {
cookie<UserSession>("ktor_session_cookie", SessionStorageMemory())
}
Next, we configure the actual Authentication
plugin. We need to provide it with two functions: a validate
function that defines how we extract a Principal
from a session, and a challenge
function that defines what to do when a non-authenticated request is made to a protected URL. In our case, the session is the principal, so we can just return it as-is. The challenge function redirects the user to /login
which will show our login form.
install(Authentication) {
session<UserSession> {
validate { session: UserSession ->
logger.info { "User ${session.name} logged in by existing session" }
session
}
challenge {
call.respondRedirect("/login")
}
}
}
Finally, we set up the routes for our demo app. On the root path /
we show a simple homepage. (Note that the code below has been edited to show the essential structure. Our Github repo has the complete working demo.) A GET
request to /login
will serve our login form. The form's action is a POST
request to /login
. The handler of that request checks the provided credentials and, if correct, stores the principal in the session.
routing {
get("/") { /* Show homepage */ }
get("/login") { /* Show login page */ }
post("/login") {
if (correctUsernameAndPassword) {
// Store principal in session
call.sessions.set(UserSession(username, roles))
call.respondRedirect("/")
} else {
// Stay on login page
call.respondRedirect("/login")
}
}
}
Now, with all this in place, protecting an endpoint from access by an unauthenticated user is as simple as wrapping the route in an authenticate
block:
routing {
authenticate {
get("/login-required") { /* Show protected content */ }
}
}
Ignoring authentication and authorization for now, let's see what happens when we setup routes as in the example below:
routing {
get("/") { /* Show homepage */ }
get("/login") { /* Show login page */ }
post("/login") { /* Handle login form */}
}
Ktor's routing feature parses this configuration into a tree of Route
instances. At the top of the tree is the root node, and every route can have zero or more child routes.
Note that not every route node maps to a path segment of the URL. As can be seen in the diagram above, separate HTTP methods are modeled as separate routes, possible targeting the same URL (as in /login
below.)
Every Route
is also a Pipeline
. In Ktor, a pipeline is a collection of interceptors, grouped in one or more ordered phases. Interceptors are similar to servlet filters; each can perform custom logic before and after processing the request, or decide to stop processing and create a response directly.
Interceptors are executed first in order of their phase, then by the order in which they were added to the phase.
By default, Ktor defines the following phases (taken from the official documentation):
// Phase for preparing the call, and processing attributes
val Setup = PipelinePhase("Setup")
// Phase for tracing calls: logging, metrics, error handling etc.
val Monitoring = PipelinePhase("Monitoring")
// Phase for infrastructure features, most intercept at this phase
val Features = PipelinePhase("Features")
// Phase for processing a call and sending a response
val Call = PipelinePhase("Call")
// Phase for handling unprocessed calls
val Fallback = PipelinePhase("Fallback")
Features usually add interceptors at certain points, either in existing phases or in newly defined phases inserted at a specific point in the chain.
Every route is also a pipeline, with its own set of phases and interceptors. At runtime, before a request is handled all the applicable pipelines are merged into a single request pipeline. The resulting pipeline contains all phases defined in the route pipelines, and every phase contains the combined set of interceptors defined in every instance of this phase. For example, suppose a GET
request for /foo
comes in. The figure below shows the result of merging the different pipelines into one:
Now that we have discussed the basics of routes and pipelines, we can understand how the authentication feature works. Consider the following route configuration:
routing {
authenticate {
get("/login-required") { /* Show protected content */ }
}
}
As a result of this configuration, an extra Route
node is added between the routes for /
and /login-required
. The new intermediate route, called /(authenticate "default")
, adds two custom phases to the default pipeline, called Authenticate
and Challenge
respectively:
The interceptors in these phases perform the authentication logic, including executing the validate
and challenge
functions we configured earlier. If a principal is found in the session, the pipeline proceeds as normal; if not, processing is stopped short by our challenge
function responding with a redirect to /login
.
Now we are ready to discuss our actual goal - adding authorization checks to routes. Suppose we want to make sure that some route /role-abc-required
is only available to users with the role ABC
. We want to keep our API in line with the way authentication works:
routing {
authenticate {
withRole("ABC") {
get("/role-abc-required") {
/* Show content meant for ABC role only */
}
}
}
}
To authorize a route, we wrap it in a withRole
block, which itself should be wrapped in an authenticate
block to ensure there is a valid principal whose roles we can check. The withRole
function takes a variable list of roles that the principal needs to have in order to access the underlying route.
The result of this configuration should be a tree of routes with an extra intermediate route to handle the role checks:
Our authorization solution adds an Authorize
phase, and configures an interceptor in that phase that checks if the current principal has sufficient privileges to access the underlying route. Since we can only perform this check if there actually is a principal defined for the current call, our phase needs to be inserted after the Challenge
phase of the authentication feature. This means that even though we don't actually add interceptors to the Challenge
, we do need to add it to our authorization pipeline to be able to define the correct position of the AuthorizationPhase
:
pipeline.insertPhaseAfter(Features, ChallengePhase)
pipeline.insertPhaseAfter(ChallengePhase, AuthorizationPhase)
The upshot of all this is that after merging the pipelines of all routes, our authorization logic will take place after authentication.
A simplified implementation of withRole
is shown below - the full version can be found in our Github repository
fun Route.withRole(val role: String, build: Route.() -> Unit): Route {
// Create a child route in the route from which this function is called
val selector = AuthorizedRouteSelector("authorize ($role)")
val authorizedRoute = createChild(selector)
// Add our authorization phase and interceptor to this route
val feature = application.feature(RoleBasedAuthorization)
feature.interceptPipeline(authorizedRoute, role)
// Proceed with building child routes and return
authorizedRoute.build()
return authorizedRoute
}
First, we create a child Route
in the parent from which we are called. This will be the route that will contain our authorization interceptors. (Attached to each route is a selector, which is used when matching a request to possible routes.)
Then we add the actual phases and interceptors, using Ktor's feature mechanism that we will discuss more below.
And finally, we proceed with building the rest of the routing tree by invoking the supplied build
function on our newly constructed route.
So, what do these lines actually do?
val feature = application.feature(RoleBasedAuthorization)
feature.interceptPipeline(authorizedRoute, role)
Let's look at interceptPipeline
first, then discuss the how and why of the feature itself. (Just as before, this is a simplified version of what's on Github.)
fun interceptPipeline(pipeline: ApplicationCallPipeline, role: String) {
pipeline.insertPhaseAfter(Features, ChallengePhase)
pipeline.insertPhaseAfter(ChallengePhase, AuthorizationPhase)
pipeline.intercept(AuthorizationPhase) {
val principal = call.authentication.principal<Principal>()
?: throw AuthorizationException("Missing principal")
val roles = getRoles(principal)
if (!roles.contains(role)) {
throw AuthorizationException("Principal lacks required role ${role}")
}
}
}
Here we set up our AuthorizationPhase
, making sure it comes after the ChallengePhase
as defined by the authentication feature. Then we add an interceptor to the AuthorizationPhase
that does a simple role check and throws an exception if the current user does not posess the required role. If the principal does have the required role we just return from the interceptor function, and Ktor will proceed down the pipeline as normal.
The only piece of the puzzle left is the getRoles
function we call to get the roles belonging to the current principal. Where does that come from? How do we know how and where to get these roles?
The answer is that that depends very much on the specifics of the application and its chosen implementation of the Principal
marker interface. We therefore have made the implementation of getRoles
a configuration item, attached to our RoleBasedAuthorization
feature.
Setting up a feature requires a bit of boilerplate which isn't very interesting - check the Github repository if you want to see how that works. The essential bit is that it defines a configuration point
var getRoles: (Principal) -> Set<String> = { emptySet() }
which can be overriden when installing the feature in your application. In our example above, we modeled the principal as:
data class UserSession(
val name: String,
val roles: Set<String> = emptySet()
) : Principal
So our getRoles
implementation then becomes a basic property lookup:
install(RoleBasedAuthorization) {
getRoles {
(it as UserSession).roles
}
}
As mentioned before, accompanying this blog post is a Github repository that contains the full source code of a working demo application. I encourage you to check it out and play with it in the debugger to get a feel for how the different parts interact.
Implementing role-based authorization in Ktor has been an interesting deep-dive into some of the internal workings of Ktor. My starting point and main inspiration was this Medium post, which provided sufficient pointers into the Ktor source code for further study. I did miss some more explanation of Ktor's design, and a working example to see how all the different parts interact - I hope this blog and demo will be of use to others.