In ktor 2 - authenticating with JWT is fairly simple.
In my case - the user will get a token with two claims that are of interest.
For this application the roles are very simply defined:
enum class Role {
USER, ADMIN
}
For the token it is a simple string representation.
However - while it is easy to mark routing blocks as requiring authentication:
authenticate {
get("/foo") {
call.respond(HttpStatusCode.OK, service.getFoo()
}
route("/admin") {
get("/bar") {
call.respond(HttpStatusCode.OK, service.getFoo()
}
}
}
I wanted some nice clean way to be able to require a role for different routes. I only need the ability to specify a single role, and can rely on the fact that we are only working with a JWTPrincipal here.
Something like this:
authenticate {
withRole(Role.USER) {
get("/foo") {
call.respond(HttpStatusCode.OK, service.getFoo()
}
}
withRole(Role.ADMIN) {
route("/admin") {
get("/bar") {
call.respond(HttpStatusCode.OK, service.getFoo()
}
}
}
}
So - implementing this using createRouteScopedPlugin
First - the configuration:
class AuthConfig {
lateinit var role: Role
}
Then - use that in the plugin:
class AuthorizationException(override val message: String? = null) : Throwable()
class AuthenticationException(override val message: String? = null) : Throwable()
val RoleBasedAuthentication = createRouteScopedPlugin(
name = "AuthorizationPlugin",
createConfiguration = ::AuthConfig,
) {
val requiredRole = pluginConfig.role
on(AuthenticationChecked) { call ->
val user = call.principal<JWTPrincipal>() ?: throw AuthenticationException(message = "Unauthenticated User")
val userRoles = user.getListClaim("roles", Role::class) ?: emptyList()
val username = user.get("username")
if (!userRoles.contains(requiredRole)) {
throw AuthorizationException(message = "User [$username] does not have required role [$requiredRole]: user: $userRoles")
}
}
}
Don't forget to handle AuthenticationException and AuthorizationException in statusPages - I log the message and return some vague 4xx error to the user :)
Finally - add some extensions on Route to make it easy to use:
class AuthorizedRouteSelector(private val desc: String) : RouteSelector() {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
RouteSelectorEvaluation.Constant
override fun toString(): String = "Authorize: $desc"
}
fun Route.withRole(role: Role, build: Route.() -> Unit) =
authorizedRoute(requiredRole = role, build = build)
private fun Route.authorizedRoute(
requiredRole: Role,
build: Route.() -> Unit,
): Route {
val authorizedRoute = createChild(AuthorizedRouteSelector(requiredRole.toString()))
authorizedRoute.install(RoleBasedAuthentication) {
role = requiredRole
}
authorizedRoute.build()
return authorizedRoute
}
At this point - the route block above using withRole() {} should now be working :)