Skip to content

Commit 068b81d

Browse files
nathanfalletbjhham
andauthored
KTOR-9162 Auth API key plugin (#5243)
* KTOR-9162 Auth API key plugin * fix multiplatform tests * addressing validate method issue (review comments) * add missing kdoc * applying review comments * better null safety * add api files * apply review feedbacks * fix build issues * fix api files * Update artifacts dump --------- Co-authored-by: Bruce Hamilton <[email protected]>
1 parent de5c910 commit 068b81d

File tree

8 files changed

+428
-0
lines changed

8 files changed

+428
-0
lines changed

gradle/artifacts/publishJvmAndCommonPublications.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,13 @@ io.ktor:ktor-serialization/.jar
331331
io.ktor:ktor-serialization/javadoc.jar
332332
io.ktor:ktor-serialization/kotlin-tooling-metadata.json
333333
io.ktor:ktor-serialization/sources.jar
334+
io.ktor:ktor-server-auth-api-key-jvm/.jar
335+
io.ktor:ktor-server-auth-api-key-jvm/javadoc.jar
336+
io.ktor:ktor-server-auth-api-key-jvm/sources.jar
337+
io.ktor:ktor-server-auth-api-key/.jar
338+
io.ktor:ktor-server-auth-api-key/javadoc.jar
339+
io.ktor:ktor-server-auth-api-key/kotlin-tooling-metadata.json
340+
io.ktor:ktor-server-auth-api-key/sources.jar
334341
io.ktor:ktor-server-auth-jvm/.jar
335342
io.ktor:ktor-server-auth-jvm/javadoc.jar
336343
io.ktor:ktor-server-auth-jvm/sources.jar
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
public final class io/ktor/server/auth/apikey/ApiKeyAuth {
2+
public static final field DEFAULT_HEADER_NAME Ljava/lang/String;
3+
public static final field INSTANCE Lio/ktor/server/auth/apikey/ApiKeyAuth;
4+
}
5+
6+
public final class io/ktor/server/auth/apikey/ApiKeyAuthKt {
7+
public static final fun apiKey (Lio/ktor/server/auth/AuthenticationConfig;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
8+
public static synthetic fun apiKey$default (Lio/ktor/server/auth/AuthenticationConfig;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
9+
}
10+
11+
public final class io/ktor/server/auth/apikey/ApiKeyAuthenticationProvider : io/ktor/server/auth/AuthenticationProvider {
12+
public fun onAuthenticate (Lio/ktor/server/auth/AuthenticationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
13+
}
14+
15+
public final class io/ktor/server/auth/apikey/ApiKeyAuthenticationProvider$Configuration : io/ktor/server/auth/AuthenticationProvider$Config {
16+
public final fun challenge (Lkotlin/jvm/functions/Function2;)V
17+
public final fun getAuthScheme ()Ljava/lang/String;
18+
public final fun getHeaderName ()Ljava/lang/String;
19+
public final fun setAuthScheme (Ljava/lang/String;)V
20+
public final fun setHeaderName (Ljava/lang/String;)V
21+
public final fun validate (Lkotlin/jvm/functions/Function3;)V
22+
}
23+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Klib ABI Dump
2+
// Targets: [androidNativeArm32, androidNativeArm64, androidNativeX64, androidNativeX86, iosArm64, iosSimulatorArm64, iosX64, js, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, wasmJs, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
3+
// Rendering settings:
4+
// - Signature version: 2
5+
// - Show manifest properties: true
6+
// - Show declarations: true
7+
8+
// Library unique name: <io.ktor:ktor-server-auth-api-key>
9+
final class io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider : io.ktor.server.auth/AuthenticationProvider { // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider|null[0]
10+
final suspend fun onAuthenticate(io.ktor.server.auth/AuthenticationContext) // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.onAuthenticate|onAuthenticate(io.ktor.server.auth.AuthenticationContext){}[0]
11+
12+
final class Configuration : io.ktor.server.auth/AuthenticationProvider.Config { // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration|null[0]
13+
final var authScheme // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.authScheme|{}authScheme[0]
14+
final fun <get-authScheme>(): kotlin/String // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.authScheme.<get-authScheme>|<get-authScheme>(){}[0]
15+
final fun <set-authScheme>(kotlin/String) // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.authScheme.<set-authScheme>|<set-authScheme>(kotlin.String){}[0]
16+
final var headerName // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.headerName|{}headerName[0]
17+
final fun <get-headerName>(): kotlin/String // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.headerName.<get-headerName>|<get-headerName>(){}[0]
18+
final fun <set-headerName>(kotlin/String) // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.headerName.<set-headerName>|<set-headerName>(kotlin.String){}[0]
19+
20+
final fun challenge(kotlin.coroutines/SuspendFunction1<io.ktor.server.application/ApplicationCall, kotlin/Unit>) // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.challenge|challenge(kotlin.coroutines.SuspendFunction1<io.ktor.server.application.ApplicationCall,kotlin.Unit>){}[0]
21+
final fun validate(kotlin.coroutines/SuspendFunction2<io.ktor.server.application/ApplicationCall, kotlin/String, kotlin/Any?>) // io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration.validate|validate(kotlin.coroutines.SuspendFunction2<io.ktor.server.application.ApplicationCall,kotlin.String,kotlin.Any?>){}[0]
22+
}
23+
}
24+
25+
final object io.ktor.server.auth.apikey/ApiKeyAuth { // io.ktor.server.auth.apikey/ApiKeyAuth|null[0]
26+
final const val DEFAULT_HEADER_NAME // io.ktor.server.auth.apikey/ApiKeyAuth.DEFAULT_HEADER_NAME|{}DEFAULT_HEADER_NAME[0]
27+
final fun <get-DEFAULT_HEADER_NAME>(): kotlin/String // io.ktor.server.auth.apikey/ApiKeyAuth.DEFAULT_HEADER_NAME.<get-DEFAULT_HEADER_NAME>|<get-DEFAULT_HEADER_NAME>(){}[0]
28+
}
29+
30+
final fun (io.ktor.server.auth/AuthenticationConfig).io.ktor.server.auth.apikey/apiKey(kotlin/String? = ..., kotlin/Function1<io.ktor.server.auth.apikey/ApiKeyAuthenticationProvider.Configuration, kotlin/Unit>) // io.ktor.server.auth.apikey/apiKey|[email protected](kotlin.String?;kotlin.Function1<io.ktor.server.auth.apikey.ApiKeyAuthenticationProvider.Configuration,kotlin.Unit>){}[0]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
plugins {
6+
id("ktorbuild.project.server-plugin")
7+
id("kotlinx-serialization")
8+
}
9+
10+
kotlin {
11+
sourceSets {
12+
commonMain.dependencies {
13+
api(projects.ktorServerAuth)
14+
}
15+
commonTest.dependencies {
16+
api(projects.ktorServerContentNegotiation)
17+
api(projects.ktorClientContentNegotiation)
18+
api(projects.ktorSerializationKotlinxJson)
19+
api(libs.kotlinx.serialization.json)
20+
}
21+
}
22+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.server.auth.apikey
6+
7+
import io.ktor.http.*
8+
import io.ktor.server.application.*
9+
import io.ktor.server.auth.*
10+
import io.ktor.server.request.*
11+
import io.ktor.server.response.*
12+
13+
/**
14+
* Installs API Key authentication mechanism.
15+
*
16+
* @param name optional name for this authentication provider. If not specified, a default name will be used.
17+
* @param configure configuration block for setting up the API Key authentication provider.
18+
*/
19+
public fun AuthenticationConfig.apiKey(
20+
name: String? = null,
21+
configure: ApiKeyAuthenticationProvider.Configuration.() -> Unit,
22+
) {
23+
val provider = ApiKeyAuthenticationProvider(ApiKeyAuthenticationProvider.Configuration(name).apply(configure))
24+
register(provider)
25+
}
26+
27+
/**
28+
* Alias for function signature that is invoked when verifying an API key from a header.
29+
*
30+
* The function receives the API key as a [String] parameter and should return an arbitrary
31+
* principal object (for example, a user or service identity) if authentication succeeds,
32+
* or null if authentication fails.
33+
*/
34+
public typealias ApiKeyAuthenticationFunction = suspend ApplicationCall.(String) -> Any?
35+
36+
/**
37+
* Alias for function signature that is called when authentication fails.
38+
*/
39+
public typealias ApiKeyAuthChallengeFunction = suspend (ApplicationCall) -> Unit
40+
41+
/**
42+
* Represents an API Key authentication provider.
43+
*
44+
* This provider extracts an API key from a specified header and validates it using
45+
* the configured authentication function.
46+
*
47+
* @param configuration the configuration for this authentication provider.
48+
*/
49+
public class ApiKeyAuthenticationProvider internal constructor(
50+
configuration: Configuration,
51+
) : AuthenticationProvider(configuration) {
52+
53+
private val headerName: String = configuration.headerName
54+
private val authenticationFunction = requireNotNull(configuration.authenticationFunction) {
55+
"API Key authentication requires a validate() function to be configured"
56+
}
57+
private val challengeFunction = configuration.challengeFunction
58+
private val authScheme = configuration.authScheme
59+
60+
override suspend fun onAuthenticate(context: AuthenticationContext) {
61+
val apiKey = context.call.request.header(headerName)
62+
val principal = apiKey?.let { authenticationFunction(context.call, it) }
63+
64+
val cause = when {
65+
apiKey == null -> AuthenticationFailedCause.NoCredentials
66+
principal == null -> AuthenticationFailedCause.InvalidCredentials
67+
else -> null
68+
}
69+
70+
if (cause != null) {
71+
context.challenge(authScheme, cause) { challenge, call ->
72+
challengeFunction(call)
73+
challenge.complete()
74+
}
75+
}
76+
if (principal != null) {
77+
context.principal(principal)
78+
}
79+
}
80+
81+
/**
82+
* Configuration for API Key authentication.
83+
*
84+
* @param name optional name for this authentication provider.
85+
*/
86+
public class Configuration internal constructor(name: String?) : Config(name) {
87+
88+
internal var authenticationFunction: ApiKeyAuthenticationFunction? = null
89+
90+
internal var challengeFunction: ApiKeyAuthChallengeFunction = { call ->
91+
call.respond(HttpStatusCode.Unauthorized)
92+
}
93+
94+
/**
95+
* Name of the scheme used when challenge fails, see [AuthenticationContext.challenge].
96+
*/
97+
public var authScheme: String = "apiKey"
98+
99+
/**
100+
* Name of the header that will be used as a source for the api key.
101+
*/
102+
public var headerName: String = ApiKeyAuth.DEFAULT_HEADER_NAME
103+
104+
/**
105+
* Sets a validation function that will check the given API key retrieved from the [headerName] header
106+
* and return an arbitrary principal object (for example, a user or service identity) if authentication
107+
* succeeds, or null if authentication fails.
108+
*
109+
* @param body the validation function that receives the API key as a [String] parameter and returns
110+
* an arbitrary principal object or null.
111+
*/
112+
public fun validate(body: ApiKeyAuthenticationFunction) {
113+
authenticationFunction = body
114+
}
115+
116+
/**
117+
* A response to send back if authentication failed.
118+
*
119+
* @param body the challenge function that handles authentication failures.
120+
*/
121+
public fun challenge(body: ApiKeyAuthChallengeFunction) {
122+
challengeFunction = body
123+
}
124+
}
125+
}
126+
127+
/**
128+
* Constants related to API Key authentication.
129+
*/
130+
public object ApiKeyAuth {
131+
/**
132+
* Default name of the header that will be used as a source for the API key.
133+
*/
134+
public const val DEFAULT_HEADER_NAME: String = "X-Api-Key"
135+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package io.ktor.server.auth.apikey
6+
7+
import io.ktor.client.call.*
8+
import io.ktor.client.plugins.contentnegotiation.*
9+
import io.ktor.client.request.*
10+
import io.ktor.http.*
11+
import io.ktor.serialization.kotlinx.json.*
12+
import io.ktor.server.auth.*
13+
import io.ktor.server.response.*
14+
import io.ktor.server.testing.*
15+
import kotlinx.serialization.Serializable
16+
import kotlin.random.Random
17+
import kotlin.test.Test
18+
import kotlin.test.assertEquals
19+
import kotlin.test.assertFailsWith
20+
21+
class ApiKeyAuthTest {
22+
23+
@Serializable
24+
private data class ApiKeyPrincipal(val key: String)
25+
26+
private val defaultHeader = "X-Api-Key"
27+
28+
@Test
29+
fun `test apikey requires validate method to be configured`() {
30+
testApplication {
31+
install(Authentication) {
32+
val exception = assertFailsWith<IllegalArgumentException> {
33+
apiKey(apiKeyAuth) {}
34+
}
35+
36+
assertEquals(
37+
"API Key authentication requires a validate() function to be configured",
38+
exception.message
39+
)
40+
}
41+
}
42+
}
43+
44+
@Test
45+
fun `test apikey auth does not influence open routes`() {
46+
val apiKey = Random.nextLong().toString()
47+
48+
val module = buildApplicationModule {
49+
validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } }
50+
}
51+
52+
testApplication {
53+
application(module)
54+
val client = createClient { install(ContentNegotiation) { json() } }
55+
56+
var response = client.get(Routes.OPEN)
57+
assertEquals(HttpStatusCode.OK, response.status)
58+
59+
response = client.get(Routes.OPEN) {
60+
header(defaultHeader, apiKey)
61+
}
62+
assertEquals(HttpStatusCode.OK, response.status)
63+
64+
response = client.get(Routes.OPEN) {
65+
header(defaultHeader, "$apiKey-wrong")
66+
}
67+
assertEquals(HttpStatusCode.OK, response.status)
68+
}
69+
}
70+
71+
@Test
72+
fun `test reasonable defaults work`() {
73+
val apiKey = Random.nextLong().toString()
74+
75+
val module = buildApplicationModule {
76+
validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } }
77+
}
78+
79+
testApplication {
80+
application(module)
81+
val client = createClient { install(ContentNegotiation) { json() } }
82+
83+
// correct header
84+
val response = client.get(Routes.AUTHENTICATED) {
85+
header(defaultHeader, apiKey)
86+
}
87+
assertEquals(HttpStatusCode.OK, response.status)
88+
val principal = response.body<ApiKeyPrincipal>()
89+
assertEquals(ApiKeyPrincipal(apiKey), principal)
90+
91+
// incorrect header
92+
val unauthorizedResponse = client.get(Routes.AUTHENTICATED) {
93+
header(defaultHeader, "$apiKey-wrong")
94+
}
95+
assertEquals(HttpStatusCode.Unauthorized, unauthorizedResponse.status)
96+
}
97+
}
98+
99+
@Test
100+
fun `test auth should accept valid api key`() {
101+
// use different from default code to verify that it actually works
102+
val errorStatus = HttpStatusCode.Conflict
103+
val header = "hello"
104+
val apiKey = "world"
105+
106+
val module = buildApplicationModule {
107+
headerName = header
108+
challenge { call -> call.respond(errorStatus) }
109+
validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } }
110+
}
111+
testApplication {
112+
application(module)
113+
val client = createClient { install(ContentNegotiation) { json() } }
114+
115+
val response = client.get(Routes.AUTHENTICATED) {
116+
header(header, apiKey)
117+
}
118+
assertEquals(HttpStatusCode.OK, response.status)
119+
val principal = response.body<ApiKeyPrincipal>()
120+
assertEquals(ApiKeyPrincipal(apiKey), principal)
121+
}
122+
}
123+
124+
@Test
125+
fun `test auth should accept reject invalid api key`() {
126+
val errorStatus = HttpStatusCode.Conflict
127+
val header = "hello"
128+
val apiKey = "world"
129+
130+
val module = buildApplicationModule {
131+
headerName = header
132+
challenge { call -> call.respond(errorStatus) }
133+
validate { header -> header.takeIf { it == apiKey }?.let { ApiKeyPrincipal(it) } }
134+
}
135+
testApplication {
136+
application(module)
137+
val client = createClient { install(ContentNegotiation) { json() } }
138+
139+
// correct header
140+
val response = client.get(Routes.AUTHENTICATED) {
141+
header(header, apiKey)
142+
}
143+
assertEquals(HttpStatusCode.OK, response.status)
144+
val principal = response.body<ApiKeyPrincipal>()
145+
assertEquals(ApiKeyPrincipal(apiKey), principal)
146+
147+
// incorrect header
148+
val unauthorizedResponse = client.get(Routes.AUTHENTICATED) {
149+
header(header, "$apiKey-wrong")
150+
}
151+
assertEquals(errorStatus, unauthorizedResponse.status)
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)