Skip to content

Commit 8faf94e

Browse files
committed
Add OIDC provider lifecycle
# Conflicts: # ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api # ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/FetchOpenIdProviderMetadataTest.kt
1 parent 8db1541 commit 8faf94e

12 files changed

Lines changed: 1229 additions & 11 deletions

File tree

ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,56 @@
1+
public final class io/ktor/server/auth/oidc/Oidc {
2+
public static final field Companion Lio/ktor/server/auth/oidc/Oidc$Companion;
3+
public final fun provider (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
4+
}
5+
6+
public final class io/ktor/server/auth/oidc/Oidc$Companion : io/ktor/server/application/BaseApplicationPlugin {
7+
public fun getKey ()Lio/ktor/util/AttributeKey;
8+
public fun install (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/auth/oidc/Oidc;
9+
public synthetic fun install (Lio/ktor/util/pipeline/Pipeline;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
10+
}
11+
12+
public final class io/ktor/server/auth/oidc/OidcKt {
13+
public static final fun getOidcMetadataRefreshFailed ()Lio/ktor/events/EventDefinition;
14+
public static final fun openIdConnect (Lio/ktor/server/application/Application;)Lio/ktor/server/auth/oidc/Oidc;
15+
public static final fun openIdConnect (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/auth/oidc/Oidc;
16+
public static synthetic fun openIdConnect$default (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/ktor/server/auth/oidc/Oidc;
17+
}
18+
19+
public final class io/ktor/server/auth/oidc/OidcMetadataRefreshFailure {
20+
public fun <init> (Lio/ktor/server/auth/oidc/OidcProvider;ILjava/lang/Throwable;)V
21+
public final fun getCause ()Ljava/lang/Throwable;
22+
public final fun getCausedByValidation ()Z
23+
public final fun getConsecutiveFailures ()I
24+
public final fun getProvider ()Lio/ktor/server/auth/oidc/OidcProvider;
25+
}
26+
27+
public final class io/ktor/server/auth/oidc/OidcPluginConfig {
28+
public fun <init> ()V
29+
public final fun getDiscoveryRefreshFailureDelay-UwyO8pc ()J
30+
public final fun getDiscoveryRefreshInterval-UwyO8pc ()J
31+
public final fun getHttpClient ()Lio/ktor/client/HttpClient;
32+
public final fun getInitialDiscoveryAttempts ()I
33+
public final fun getInitialDiscoveryRetryDelay-UwyO8pc ()J
34+
public final fun setDiscoveryRefreshFailureDelay-LRDsOJo (J)V
35+
public final fun setDiscoveryRefreshInterval-LRDsOJo (J)V
36+
public final fun setHttpClient (Lio/ktor/client/HttpClient;)V
37+
public final fun setInitialDiscoveryAttempts (I)V
38+
public final fun setInitialDiscoveryRetryDelay-LRDsOJo (J)V
39+
}
40+
41+
public final class io/ktor/server/auth/oidc/OidcProvider {
42+
public final fun currentMetadata ()Lio/ktor/server/auth/oidc/OpenIdProviderMetadata;
43+
public final fun getIssuer ()Ljava/lang/String;
44+
public final fun getName ()Ljava/lang/String;
45+
}
46+
47+
public final class io/ktor/server/auth/oidc/OidcProviderConfig {
48+
public field issuer Ljava/lang/String;
49+
public final fun getIssuer ()Ljava/lang/String;
50+
public final fun getName ()Ljava/lang/String;
51+
public final fun setIssuer (Ljava/lang/String;)V
52+
}
53+
154
public final class io/ktor/server/auth/oidc/OpenIdDiscoveryException : java/lang/RuntimeException {
255
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
356
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V

ktor-server/ktor-server-plugins/ktor-server-auth-oidc/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ plugins {
1010
kotlin {
1111
sourceSets {
1212
jvmMain.dependencies {
13+
api(projects.ktorServerCore)
1314
api(projects.ktorClientCore)
1415
api(projects.ktorClientContentNegotiation)
1516
api(projects.ktorSerializationKotlinxJson)
1617
api(libs.kotlinx.serialization.json)
18+
api(projects.ktorClientContentNegotiation)
19+
api(projects.ktorSerializationKotlinxJson)
1720
}
1821
jvmTest.dependencies {
1922
implementation(projects.ktorServerTestHost)
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/*
2+
* Copyright 2014-2026 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.oidc
6+
7+
import io.ktor.client.*
8+
import io.ktor.client.plugins.contentnegotiation.*
9+
import io.ktor.events.*
10+
import io.ktor.events.EventDefinition
11+
import io.ktor.serialization.kotlinx.json.*
12+
import io.ktor.server.application.*
13+
import io.ktor.util.*
14+
import io.ktor.utils.io.*
15+
import kotlinx.coroutines.*
16+
import kotlinx.coroutines.CancellationException
17+
import kotlinx.coroutines.sync.Mutex
18+
import kotlinx.coroutines.sync.withLock
19+
import kotlinx.serialization.json.Json
20+
21+
private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*")
22+
23+
/**
24+
* First-class OpenID Connect plugin for Ktor server authentication.
25+
*
26+
* Providers are registered from a suspend application module because registration performs initial discovery.
27+
* Provider metadata is fetched from the issuer's discovery document
28+
* (`<issuer>/.well-known/openid-configuration`) and periodically refreshed.
29+
*
30+
* Environment-based configuration is used only as a default when a provider with the same name is registered in code;
31+
* environment entries that are not directly registered are ignored. After the final failed discovery attempt,
32+
* registration fails with a [OpenIdDiscoveryException]. Discovery work runs on [Dispatchers.IO].
33+
*
34+
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.Oidc)
35+
*/
36+
public class Oidc internal constructor(
37+
private val application: Application,
38+
private val config: OidcPluginConfig,
39+
private val client: HttpClient,
40+
) {
41+
@Volatile
42+
private var providers: Map<String, OidcProvider> = linkedMapOf()
43+
44+
private val pendingProviderNames = mutableSetOf<String>()
45+
private val pendingProviderIssuers = mutableSetOf<String>()
46+
private val providerRegistrationMutex = Mutex()
47+
48+
/**
49+
* Configures an OpenID Connect provider.
50+
*
51+
* @param name provider name. Must contain lowercase letters, digits, and hyphen-separated segments only.
52+
* @param configure configures provider discovery settings.
53+
* @return configured provider.
54+
* @throws IllegalArgumentException when [name] or issuer is already configured, or the provider
55+
* configuration is invalid.
56+
* @throws OpenIdDiscoveryException when initial provider discovery fails after all configured attempts.
57+
*/
58+
public suspend fun provider(
59+
name: String,
60+
configure: OidcProviderConfig.() -> Unit,
61+
): OidcProvider {
62+
val config = reserveProviderName(name, configure)
63+
try {
64+
val provider = discoverProvider(config)
65+
commitProvider(provider)
66+
return provider
67+
} catch (e: Exception) {
68+
releaseProvider(name, config.issuer)
69+
throw e
70+
}
71+
}
72+
73+
private suspend fun reserveProviderName(
74+
name: String,
75+
configure: OidcProviderConfig.() -> Unit
76+
): OidcProviderConfig = providerRegistrationMutex.withLock {
77+
require(name.matches(ProviderNameRegex)) {
78+
"OpenID Connect provider name $name is invalid. Use lowercase letters, digits, and hyphen-separated segments"
79+
}
80+
require(name !in providers && pendingProviderNames.add(name)) {
81+
"OpenID Connect provider $name is already configured"
82+
}
83+
84+
val providerConfig = OidcProviderConfig(name).apply {
85+
config.environmentProviders[name]?.let { env -> issuer = env.issuer }
86+
}
87+
try {
88+
providerConfig.configure()
89+
providerConfig.validate()
90+
val issuer = providerConfig.issuer
91+
require(providers.values.none { it.issuer == issuer } && pendingProviderIssuers.add(issuer)) {
92+
"Duplicate OIDC issuer found for provider $name: $issuer"
93+
}
94+
providerConfig
95+
} catch (e: Throwable) {
96+
pendingProviderNames.remove(name)
97+
throw e
98+
}
99+
}
100+
101+
private suspend fun discoverProvider(config: OidcProviderConfig): OidcProvider {
102+
val provider = OidcProvider(config.name, client, config)
103+
val metadata = withContext(Dispatchers.IO) {
104+
discoverInitialMetadata(provider)
105+
}
106+
provider.updateMetadata(metadata)
107+
return provider
108+
}
109+
110+
private suspend fun commitProvider(provider: OidcProvider) = providerRegistrationMutex.withLock {
111+
application.startRefreshingMetadata(provider)
112+
providers = providers + (provider.name to provider)
113+
config.environmentProviders.remove(provider.name)
114+
pendingProviderNames.remove(provider.name)
115+
pendingProviderIssuers.remove(provider.issuer)
116+
}
117+
118+
private suspend fun releaseProvider(name: String, issuer: String?) {
119+
providerRegistrationMutex.withLock {
120+
pendingProviderNames.remove(name)
121+
pendingProviderIssuers.remove(issuer)
122+
}
123+
}
124+
125+
private suspend fun discoverInitialMetadata(provider: OidcProvider): OpenIdProviderMetadata {
126+
var lastFailure: Throwable? = null
127+
repeat(config.initialDiscoveryAttempts) { attempt ->
128+
try {
129+
return client.discoverVerified(provider.issuer)
130+
} catch (cause: CancellationException) {
131+
throw cause
132+
} catch (cause: IllegalArgumentException) {
133+
throw cause
134+
} catch (cause: Throwable) {
135+
lastFailure = cause
136+
val nextAttempt = attempt + 1
137+
if (nextAttempt < config.initialDiscoveryAttempts) {
138+
provider.logger.warn(
139+
"OpenID configuration discovery failed. Retrying attempt ${nextAttempt + 1}/${config.initialDiscoveryAttempts}: ${cause.message}"
140+
)
141+
delay(config.initialDiscoveryRetryDelay)
142+
}
143+
}
144+
}
145+
throw OpenIdDiscoveryException(
146+
"Failed to discover OpenID configuration after ${config.initialDiscoveryAttempts} attempt(s)",
147+
lastFailure,
148+
)
149+
}
150+
151+
private fun Application.startRefreshingMetadata(provider: OidcProvider) {
152+
if (!config.discoveryRefreshInterval.isPositive()) {
153+
return
154+
}
155+
launch(Dispatchers.IO) {
156+
var hasPreviousFailure = false
157+
var consecutiveFailures = 0
158+
while (isActive) {
159+
val duration = if (hasPreviousFailure) {
160+
hasPreviousFailure = false
161+
config.discoveryRefreshFailureDelay
162+
} else {
163+
config.discoveryRefreshInterval
164+
}
165+
delay(duration)
166+
try {
167+
val newMetadata = client.discoverVerified(provider.issuer)
168+
provider.updateMetadata(newMetadata)
169+
consecutiveFailures = 0
170+
} catch (cause: CancellationException) {
171+
throw cause
172+
} catch (cause: Throwable) {
173+
consecutiveFailures++
174+
val event = OidcMetadataRefreshFailure(provider, consecutiveFailures, cause)
175+
monitor.raiseCatching(
176+
definition = OidcMetadataRefreshFailed,
177+
value = event,
178+
logger = provider.logger
179+
)
180+
hasPreviousFailure = true
181+
}
182+
}
183+
}
184+
}
185+
186+
public companion object : BaseApplicationPlugin<Application, OidcPluginConfig, Oidc> {
187+
override val key: AttributeKey<Oidc> = AttributeKey("OIDC")
188+
189+
@OptIn(ExperimentalKtorApi::class)
190+
override fun install(
191+
pipeline: Application,
192+
configure: OidcPluginConfig.() -> Unit
193+
): Oidc {
194+
val config = OidcPluginConfig().apply {
195+
pipeline.loadConfigFromEnvironment()
196+
configure()
197+
validate()
198+
}
199+
200+
val managedClient = config.httpClient ?: defaultOpenIdHttpClient()
201+
if (config.httpClient == null) {
202+
pipeline.monitor.subscribe(ApplicationStopped) { managedClient.close() }
203+
}
204+
205+
val plugin = Oidc(
206+
application = pipeline,
207+
config = config,
208+
client = managedClient,
209+
)
210+
211+
pipeline.monitor.subscribe(ApplicationModulesLoaded) {
212+
if (plugin.providers.isEmpty()) {
213+
pipeline.log.warn("No OpenID Connect issuers configured.")
214+
}
215+
}
216+
217+
return plugin
218+
}
219+
}
220+
}
221+
222+
/**
223+
* Details of a failed periodic OpenID Connect discovery metadata refresh.
224+
* Routes and token validation continue with the last successful discovery document.
225+
*
226+
* @property provider OpenID Connect provider instance.
227+
* @property consecutiveFailures number of consecutive periodic refresh failures, reset after a successful refresh.
228+
* @property cause failure raised while fetching or validating discovery metadata.
229+
*/
230+
public class OidcMetadataRefreshFailure(
231+
public val provider: OidcProvider,
232+
public val consecutiveFailures: Int,
233+
public val cause: Throwable
234+
) {
235+
public val causedByValidation: Boolean = cause is IllegalArgumentException
236+
}
237+
238+
/**
239+
* Monitor event raised when a periodic OpenID Connect discovery metadata refresh fails.
240+
*
241+
* Subscribe to this event with [Application.monitor]. Initial discovery failures are reported through provider
242+
* registration exceptions and do not raise this event.
243+
*/
244+
public val OidcMetadataRefreshFailed: EventDefinition<OidcMetadataRefreshFailure> = EventDefinition()
245+
246+
/**
247+
* Installs [Oidc] in this application and returns the provider registry.
248+
*
249+
* Use the returned [Oidc] to declare providers and keep provider configuration close to application setup.
250+
* Provider registration is suspendable because it performs initial discovery.
251+
*
252+
* @param configure plugin configuration block.
253+
* @return installed OpenID Connect provider registry.
254+
* @throws IllegalArgumentException when environment-based provider configuration is invalid.
255+
*/
256+
public fun Application.openIdConnect(
257+
configure: OidcPluginConfig.() -> Unit = {},
258+
): Oidc =
259+
install(Oidc, configure)
260+
261+
/**
262+
* Returns the installed [Oidc] plugin instance.
263+
*
264+
* Convenience wrapper for `plugin(Oidc)`.
265+
*
266+
* @return installed OpenID Connect plugin instance.
267+
* @throws MissingApplicationPluginException when [Oidc] is not installed.
268+
*/
269+
public fun Application.openIdConnect(): Oidc = plugin(Oidc)
270+
271+
private fun defaultOpenIdHttpClient(): HttpClient = HttpClient {
272+
install(ContentNegotiation) {
273+
val format = Json {
274+
ignoreUnknownKeys = true
275+
isLenient = true
276+
}
277+
json(format)
278+
}
279+
}

0 commit comments

Comments
 (0)