Документация Ktor по server-jwt неполна. Если необходимо сделать что-то за рамками «Hello world», придется лезть в исходники и городить костыли. Какой-то консистентности и предсказуемости ждать не стоит, возможно, не обошлось без заговорщиков.

Статья покроет необходимую базу для работы с JWT и убережет от множества подводных камней.
Перед чтением ознакомьтесь
Про JWT-аутентификацию можно почитать на хабре.
Терминология Ktor
Principal
— клиент. Пример — io.ktor.server.auth.jwt.JWTPrincipal
. Почему не User
? Клиентом может быть другой сервис, не обязательно пользователь.
HttpAuthHeader
— sealed class
c двумя реализациями: Single
и Parameterized
.
HttpAuthHeader.Parameterized
— хэдер по типу Header: param1="value1", param2="value2"
. Примеры: digest, HOBA. В статье касаться не будем.
HttpAuthHeader.Single
— хэдер по типу Header: Scheme Blob.
Два популярных варианта:
Basic аутентификация с логином и паролем в base64:
Authorization: Basic bG9naW46cGFzc3dvcmQ=
.Авторизация с JWT:
Authorization: Bearer <headers>.<payload>.<signature>
.
Как подключить и настроить JWT аутентификацию
Добавляем зависимости:
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
// опционально для отлова ошибок
implementation("io.ktor:ktor-server-status-pages:$ktor_version")
Используем:
routing {
// Используем одного Authentication.provider
authenticate("ClientA") {
get("/") {
// 0. Достаем информацию о клиенте (payload токена)
val client = call.principal()
call.respondText("Hello World!")
}
}
}
Конфигурируем JWT-аутентификацию. Настройки указаны в том порядке, в котором они вызываются в коде:
install(Authentication) {
jwt("ClientA") { // регистрируем AuthenticationConfig.providers
// 1. Указываем, как достать заголовок аутентификации
authHeader {call -> /*...*/ }
// 2. Цель блока — проверить формат, подпись и срок годности
verifier { httpAuthHeader -> /*...*/ }
// 3. Валидация JWT.payload (какое-то поле не того типа или отсутствует)
validate { credential: JWTCredential -> /*...*/ }
// 4. Реагируем на ошибку
challenge { defaultScheme, realm -> /*...*/}
}
}
// Опционально.
install(StatusPages) {
// 5. Сюда прилетают ошибки из некоторых блоков.
exception { call, e ->
call.application.log.error("Error", e)
// тут можем переопределить ответ с call.respond
}
}
// 6. Где-то в глубинах ктор
// internal val JWTLogger: Logger = LoggerFactory.getLogger("io.ktor.auth.jwt")
Здесь может показаться, что блок challenge
сработает, когда в авторизации что-то не так, как и было заявлено.
The
challenge
function allows you to configure a response to be sent if authentication fails. — Документация Ktor
Если бы было так, то и статья бы не понадобилась.
Обозначим еще один термин, который нам понадобится дальше: УСПЕШНЫЙ СЦЕНАРИЙ аутентификации. Сценарий будет успешным, если authHeader
(1) вернет заголовок, verifier
(2) не упадет, validate
(3) вернет non-null Principal
, challenge
(4) иexception
(5) блоки не запустятся.
Детали конфигурации с одним Authentication.providerм
Все, что не проходит по успешному сценарию, приводит к ответу 401.
Пункт 1, authHeader
. Если его не переопределить, будет использоваться заголовок «Authorization». Если из него вернуть null, попадём в challenge
(4) блок. Если в нем упасть, то не попадем вchallenge
(4) и в логи ничего не напишется (6), но попадем в exception
(5). Пример реализации:
authHeader { call ->
call.request.header("X-Custom-Auth")?.let { parseAuthorizationHeader(it) }
}
Пункт 2, verifier
. Блок обязательный — если не переопределить, попадём в challenge
(4). Если упасть внутри блока, попадём только в лог (6). Если JWTVerifier вернет ошибку, попадем в challenge
(4), но без деталей падения. Детали можно будет найти в логе (6). В exception
тоже не попадем. Примеры реализации есть в документации, вот еще один, на основании публичного ключа:
private val keyFactory: KeyFactory = KeyFactory.getInstance("RSA")
fun jwtVerifier(): JWTVerifier {
val publicKey = File("path/to/public.pem").readText()
.replace("\n", "")
.replace("\r", "")
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
val keySpecX509 = X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyContent))
val pubKey = keyFactory.generatePublic(keySpecX509) as RSAPublicKey
val algo = Algorithm.RSA256(pubKey, null)
return JWT.require(algo).build()
}
verifier { httpAuthHeader ->
if (httpAuthHeader is HttpAuthHeader.Single) {
jwtVerifier()
} else {
environment.log.error("HttpAuthHeader does not adhere scheme: ")
null
}
}
Пункт 3, validate
. Для успешного сценария нужно вернуть non-null объект из блока, который будет в дальнейшем обозначать клиента. Его можно достать внутри запроса через вызов call.principal<T>()
.
По задумке авторов на ошибках валидации следует не падать, а возвращать null. Если же в блоке выбросится ошибка, мы не попадём ни вchallenge
(4), ни вexception
(5), но напечатаем лог (6). Пример реализации:
validate { credential: JWTCredential ->
val scopes = credential.payload.getClaim("scopes").asList(String::class.java)
if (scopes == null) return@validate null
JWTPrincipal(credential.payload)
}
Пункт 4, challenge
. Блок запускается в следующих случаях:
Если
authHeader
вернет null.Не реализовали
verifier
(2) (странно, что они вообще дают компилироваться в таком случае).Если
JWTVerifier
, созданный вverifier
(2), выбросит исключение.Если
validate
(3) вернетnull
. Если внутри произойдет падение, ошибка придет вexception
. Пример реализации:
challenge { defaultScheme: String, realm: String ->
// Bearer, Ktor Server
val failures: List<AuthenticationFailedCause> = call.authentication.allFailures
failures.forEach { call.application.log.error("failure: $it") }
call.respondText(status = HttpStatusCode.Unauthorized) {
"Authentication error, but what exactly?"
}
}
Всё бы ничего, но вытащить информацию о причине ошибки не так-то просто. AuthenticationFailedCause
— просто объект без информации: либо NoCredentials
, либо InvalidCredentials
. А вот AuthenticationFailedCause.Error
вообще никем не возвращается в реализации JWTAuthenticationProvider
. Error
может прийти при использовании Ktor oauth, что выходит за рамки данной статьи.
Таблица ветвления Ktor jwt auth
Ошибка в блоке ↓ |
challenge |
log |
exception |
---|---|---|---|
|
+ |
– |
– |
|
– |
– |
+ |
|
+ |
– |
– |
|
– |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
– |
+ |
– |
|
– |
– |
+ |
Хотелось бы, что challenge-блок отрабатывал во всех случаях (кроме самого challenge-блока), и я написал подобную реализацию в тестовом проекте (github).
Как определить несколько типов аутентификации
Все вышеописанные правила из предыдущего раздела меняются при определении нескольких типов аутентификации в зависимости от используемой AuthenticationStrategy
.
routing {
authenticate(
"ClientA",
"ClientB",
// стратегия по умолчанию
strategy = AuthenticationStrategy.FirstSuccessful,
) {/*...*/}
}
Имеющиеся 3 стратегии понятны интуитивно:
enum class AuthenticationStrategy { Optional, FirstSuccessful, Required }
AuthenticationStrategy.Optional
— если ни один из клиентов не пройдет успешный сценарий аутентификации, то мы не попадем в exception
(5) блок и не получим 401, а внутри ручки при вызове call.principal()
вернется null
.
AuthenticationStrategy.FirstSuccessful
— первый из клиентов, который пройдет успешный сценарий, отработает и не позволит отработать больше никому. Jwt-блоки клиентов будут вызваны в том порядке, в котором представлены в методе authenticate
. Те, кто успел упасть в любом из блоков, ни на что не повлияют, кроме логов (6), а challenge
(4) и exception
(5) не запустятся. В call.principal()
вернется Principal
первого успешного. Если никто не пройдет успешный сценарий, вернется 401 или то, что переопределено на уровне challenge
(4) последнего указанного типа аутентификации или общего блока exception
(5).
AuthenticationStrategy.Required
— все клиенты должны пройти успешный сценарий, иначе получим 401. В call.principal<Any>()
вернется principal первого указанного в authenticate
клиента («ClientA» в примере выше из этого раздела). Но если вызвать call.principal<ClientBPrincipal>()
, то вернется ClientBPrincipal
.
Глубокое заглатывание
Ктор поражает гибкостью, достойной самых смелых ассоциаций с подпиленными ребрами.
Представьте, что вы хотите попасть в крартиру. В дом можно попасть либо через парадный подъезд с лифтом, либо через боковой подъезд с лестницей. От обоих подъездов нужны ключи. Внутри дома будет квартира, от которой тоже нужен ключ.
object Auth {
val scope1 = "ключ от парадного подъезда"
val scope2 = "ключ от бокового подъезда"
val scope3 = "ключ от квартиры"
}
route("building/") {
authenticate(Auth.scope1, Auth.scope2, strategy = FirstSuccessful) {
route("flat") {
authenticate(Auth.scope3, strategy = Required) {
get() { /*..*/ }
post() { /*..*/ }
}
}
}
}
Выглядит так, что в рамках запроса к flat
нужны 2 ключа — один от дома (scope1 или scope2), другой от квартиры (scope3).
Но это не так!
Для доступа к ручке достаточно любого из ключей, даже если это не scope3
, который помечен как required
. Такое поведение происходит от того, что все authenticate
равнозначны, а в порядке определения первой шла стратегия FirstSuccessful
.
Но что, если мы хотим ожидаемое поведение (scope1 || scope2) && scope3
. Как думаете, будут ли работать две реализации ниже?
authenticate("scope3", strategy = AuthenticationStrategy.Required) {
route("building/flat") {
authenticate("scope1", strategy = AuthenticationStrategy.Required) {
get() { /*..*/ }
post() { /*..*/ }
}
authenticate("scope2", strategy = AuthenticationStrategy.Required) {
get() { /*..*/ }
post() { /*..*/ }
}
}
}
Вторая реализация:
route("building/flat") {
authenticate("scope1" ,"scope3", strategy = AuthenticationStrategy.Required) {
get() { /*..*/ }
post() { /*..*/ }
}
authenticate("scope2" ,"scope3", strategy = AuthenticationStrategy.Required) {
get() { /*..*/ }
post() { /*..*/ }
}
}
Обе будут работать не так, как мы хотим. Пройдут только запросы со scope1
и scope3
, т.к. они объявлены первыми. Решить задачу через попытку достать несколько principal и проверить, что есть scope1
или scope2
— тоже не получится. Какие бы стратегии ни использовать, будет только один non-null principal:
route("building/") {
authenticate(Auth.scope1, strategy = <FirstSuccessful | Optinal>) {
route("flat") {
authenticate(Auth.scope2, strategy = <любая стратегия>) {
get() {
val principal1 = call.principal1<Principal1>()
val principal2 = call.principal1<Principal2>()
val alwaysTrue = principal1 == null || principal2 == null
}
}
}
}
}
Кажется, что разработчики просто попробовали реализовать стратегии, как получится, и удовлетворились первым решением, которое скомпилировалось.
Не буду больше мучить читателя и сообщу, что на уровне AuthenticationStrategy
задачу не решить. Но можно создать отдельный scope4
, которые на уровне определения удовлетворится условием (scope1 || scope2) && scope3
:
object Scope4Principal
// в блоке install(Authentication)
jwt("scope4") {
verifier { makeVerfier() }
validate { credential: JWTCredential ->
val scopes = credential.payload.getClaim("scopes").asList(String::class.java)
?: emptyList()
if (scopes.contains("scope3") &&
(scopes.contains("scope1") || scopes.contains("scope2")))
Scope4Principal
} else {
null
}
}
}
// в блоке routing
authenticate(Auth.scope4) {
route("building/flat") {
get() { /*..*/ }
post() { /*..*/ }
}
}
Как узнать о причине InvalidCredentials
— Зачем узнавать? — спрашивает читатель?
— Чтобы ускорить разбор проблем, посмотрев в логи. Либо изменить ответ в зависимости от типа ошибки (если безопасники не против).
Казалось бы, это должно быть очевидно и легко сделать, но нет. В блоке challenge
(4) есть доступ только до call.authentication.allErrors
и call.authentication.allFailures
, которые не содержат ничего, кроме одного из двух слов: InvalidCredentials
или NoCredentials
.
По исходникам видно, что вся полезная информация теряется в блоке catch
:
internal suspend fun verifyAndValidate(
call: ApplicationCall,
jwtVerifier: JWTVerifier?,
token: HttpAuthHeader,
schemes: JWTAuthSchemes,
validate: suspend ApplicationCall.(JWTCredential) -> Any?
): Any? {
val jwt = try {
token.getBlob(schemes)?.let { jwtVerifier?.verify(it) }
} catch (cause: JWTVerificationException) {
JWTLogger.debug("JWT verification failed: ${cause.message}", cause)
// тут и потерялась вся информация о `cause`, но мы можем зацепиться за JWTLogger
null
} ?: return null
/*...*/
}
Если достаточно только логов, то Ktor использует org.slf4j.Logger
с именем io.ktor.auth.jwt
. Вот пример того, как добавить вывод первых трех строк по ошибкам с jwt от Ktor с logback.xml:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
<!--The below settings are used to show io.ktor.server.auth.jwt issues-->
<appender name="STDOUT_SHORT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{2} - %msg%n{2}</pattern>
</encoder>
</appender>
<root level="TRACE">
<appender-ref ref="STDOUT_SHORT"/>
</root>
<logger name="io.ktor.auth.jwt" level="TRACE" additivity="false">
<appender-ref ref="STDOUT_SHORT"/>
</logger>
</configuration>
Мне этот вариант не нравится, потому что приходится зависеть от деталей реализации. Что если в следующем обновлении переименуют логгер или сообщение от ошибке? Не будем же мы постоянно следить за этим.
Без костылей достать информацию, чтобы изменить ответ, нельзя.
Есть вариант — самостоятельно вызвать JWTVerifier еще раз, чтобы получить ошибку. Решение плохое: придется внутри challengе
еще раз доставать заголовок и еще раз вызывать verify
. Ладно бы только это, но ведь нет гарантий, что мы попадем в этот challenge
(см. таблицу ветвления ktor jwt auth выше).
Иллюзорный вариант — обернуть JWTVerifier
в свою реализацию, которая будет запоминать ошибки.
class SpyJwtVerifier(private val delegate: JWTVerifier) : JWTVerifier by delegate {
var exception: Exception? = null
private set
override fun verify(token: String?): DecodedJWT = try {
delegate.verify(token)
} catch (e: Exception) {
exception = e
throw e
}
}
jwt("ClientB") {
val spyVerifier = SpyJwtVerifier(webTokenVerifier())
verifier { httpAuthHeader -> spyVerifier }
challenge { _, _ ->
spyVerifier.exception?.let { e ->
call.application.log.error("Verification error")
call.respond(HttpStatusCode.Unauthorized, mapOf("message" to e.toString()))
return@let
}
call.respond(
HttpStatusCode.Unauthorized,
mapOf("message" to call.authentication.allFailures.first())
)
}
}
Проблема тут в том, что spyVerifier
создается один раз. Будет гонка.
Единственное решение проблемы — реализовать свой собственный AuthenticationProvider
. Самый просто вариант — взять исходники и поправить две функции onAuthenticate
(расширить try-catch блок и вызывать challenge
в catch) и ловить ошибки внутри verifyAndValidate
. Детали можно посмотреть в тестовом проекте. Таблица ветвления становится более предсказуемой:
Ошибка в блоке ↓ |
challenge |
log |
exception |
---|---|---|---|
|
+ |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
+ |
+ |
– |
|
– |
– |
+ |
Вместо заключения
В статье рассмотрели детали настройки и особенности поведения ktor-server-auth. На многие незаданные вопросы можно ответить, вернувшись к таблица ветвления Ktor jwt auth в середине статьи.
Поиграться с библиотекой можно в тестовом проекте (github), там уже есть тесты и поправленная реализация AuthenticationProvider.