diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api index a76d0462e72..7ff75392d01 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api @@ -1,3 +1,56 @@ +public final class io/ktor/server/auth/oidc/Oidc { + public static final field Companion Lio/ktor/server/auth/oidc/Oidc$Companion; + public final fun provider (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/ktor/server/auth/oidc/Oidc$Companion : io/ktor/server/application/BaseApplicationPlugin { + public fun getKey ()Lio/ktor/util/AttributeKey; + public fun install (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/auth/oidc/Oidc; + public synthetic fun install (Lio/ktor/util/pipeline/Pipeline;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; +} + +public final class io/ktor/server/auth/oidc/OidcKt { + public static final fun getOidcMetadataRefreshFailed ()Lio/ktor/events/EventDefinition; + public static final fun openIdConnect (Lio/ktor/server/application/Application;)Lio/ktor/server/auth/oidc/Oidc; + public static final fun openIdConnect (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/auth/oidc/Oidc; + public static synthetic fun openIdConnect$default (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/ktor/server/auth/oidc/Oidc; +} + +public final class io/ktor/server/auth/oidc/OidcMetadataRefreshFailure { + public fun (Lio/ktor/server/auth/oidc/OidcProvider;ILjava/lang/Throwable;)V + public final fun getCause ()Ljava/lang/Throwable; + public final fun getCausedByValidation ()Z + public final fun getConsecutiveFailures ()I + public final fun getProvider ()Lio/ktor/server/auth/oidc/OidcProvider; +} + +public final class io/ktor/server/auth/oidc/OidcPluginConfig { + public fun ()V + public final fun getDiscoveryRefreshFailureDelay-UwyO8pc ()J + public final fun getDiscoveryRefreshInterval-UwyO8pc ()J + public final fun getHttpClient ()Lio/ktor/client/HttpClient; + public final fun getInitialDiscoveryAttempts ()I + public final fun getInitialDiscoveryRetryDelay-UwyO8pc ()J + public final fun setDiscoveryRefreshFailureDelay-LRDsOJo (J)V + public final fun setDiscoveryRefreshInterval-LRDsOJo (J)V + public final fun setHttpClient (Lio/ktor/client/HttpClient;)V + public final fun setInitialDiscoveryAttempts (I)V + public final fun setInitialDiscoveryRetryDelay-LRDsOJo (J)V +} + +public final class io/ktor/server/auth/oidc/OidcProvider { + public final fun currentMetadata ()Lio/ktor/server/auth/oidc/OpenIdProviderMetadata; + public final fun getIssuer ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; +} + +public final class io/ktor/server/auth/oidc/OidcProviderConfig { + public field issuer Ljava/lang/String; + public final fun getIssuer ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun setIssuer (Ljava/lang/String;)V +} + public final class io/ktor/server/auth/oidc/OpenIdDiscoveryException : java/lang/RuntimeException { public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/build.gradle.kts b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/build.gradle.kts index 7dfb0f490d4..09985a3b7c4 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/build.gradle.kts +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/build.gradle.kts @@ -10,10 +10,13 @@ plugins { kotlin { sourceSets { jvmMain.dependencies { + api(projects.ktorServerCore) api(projects.ktorClientCore) api(projects.ktorClientContentNegotiation) api(projects.ktorSerializationKotlinxJson) api(libs.kotlinx.serialization.json) + api(projects.ktorClientContentNegotiation) + api(projects.ktorSerializationKotlinxJson) } jvmTest.dependencies { implementation(projects.ktorServerTestHost) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt new file mode 100644 index 00000000000..4e4f27cf9f0 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.events.* +import io.ktor.events.EventDefinition +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.util.* +import io.ktor.utils.io.* +import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json + +private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*") + +/** + * First-class OpenID Connect plugin for Ktor server authentication. + * + * Providers are registered from a suspend application module because registration performs initial discovery. + * Provider metadata is fetched from the issuer's discovery document + * (`/.well-known/openid-configuration`) and periodically refreshed. + * + * Environment-based configuration is used only as a default when a provider with the same name is registered in code; + * environment entries that are not directly registered are ignored. After the final failed discovery attempt, + * registration fails with a [OpenIdDiscoveryException]. Discovery work runs on [Dispatchers.IO]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.Oidc) + */ +public class Oidc internal constructor( + private val application: Application, + private val config: OidcPluginConfig, + private val client: HttpClient, +) { + @Volatile + private var providers: Map = linkedMapOf() + + private val pendingProviderNames = mutableSetOf() + private val pendingProviderIssuers = mutableSetOf() + private val providerRegistrationMutex = Mutex() + + /** + * Configures an OpenID Connect provider. + * + * @param name provider name. Must contain lowercase letters, digits, and hyphen-separated segments only. + * @param configure configures provider discovery settings. + * @return configured provider. + * @throws IllegalArgumentException when [name] or issuer is already configured, or the provider + * configuration is invalid. + * @throws OpenIdDiscoveryException when initial provider discovery fails after all configured attempts. + */ + public suspend fun provider( + name: String, + configure: OidcProviderConfig.() -> Unit, + ): OidcProvider { + val config = reserveProviderName(name, configure) + try { + val provider = discoverProvider(config) + commitProvider(provider) + return provider + } catch (e: Exception) { + releaseProvider(name, config.issuer) + throw e + } + } + + private suspend fun reserveProviderName( + name: String, + configure: OidcProviderConfig.() -> Unit + ): OidcProviderConfig = providerRegistrationMutex.withLock { + require(name.matches(ProviderNameRegex)) { + "OpenID Connect provider name $name is invalid. Use lowercase letters, digits, and hyphen-separated segments" + } + require(name !in providers && pendingProviderNames.add(name)) { + "OpenID Connect provider $name is already configured" + } + + val providerConfig = OidcProviderConfig(name).apply { + config.environmentProviders[name]?.let { env -> issuer = env.issuer } + } + try { + providerConfig.configure() + providerConfig.validate() + val issuer = providerConfig.issuer + require(providers.values.none { it.issuer == issuer } && pendingProviderIssuers.add(issuer)) { + "Duplicate OIDC issuer found for provider $name: $issuer" + } + providerConfig + } catch (e: Throwable) { + pendingProviderNames.remove(name) + throw e + } + } + + private suspend fun discoverProvider(config: OidcProviderConfig): OidcProvider { + val provider = OidcProvider(config.name, client, config) + val metadata = withContext(Dispatchers.IO) { + discoverInitialMetadata(provider) + } + provider.updateMetadata(metadata) + return provider + } + + private suspend fun commitProvider(provider: OidcProvider) = providerRegistrationMutex.withLock { + application.startRefreshingMetadata(provider) + providers = providers + (provider.name to provider) + config.environmentProviders.remove(provider.name) + pendingProviderNames.remove(provider.name) + pendingProviderIssuers.remove(provider.issuer) + } + + private suspend fun releaseProvider(name: String, issuer: String?) { + providerRegistrationMutex.withLock { + pendingProviderNames.remove(name) + pendingProviderIssuers.remove(issuer) + } + } + + private suspend fun discoverInitialMetadata(provider: OidcProvider): OpenIdProviderMetadata { + var lastFailure: Throwable? = null + repeat(config.initialDiscoveryAttempts) { attempt -> + try { + return client.discoverVerified(provider.issuer) + } catch (cause: CancellationException) { + throw cause + } catch (cause: IllegalArgumentException) { + throw cause + } catch (cause: Throwable) { + lastFailure = cause + val nextAttempt = attempt + 1 + if (nextAttempt < config.initialDiscoveryAttempts) { + provider.logger.warn( + "OpenID configuration discovery failed. Retrying attempt ${nextAttempt + 1}/${config.initialDiscoveryAttempts}: ${cause.message}" + ) + delay(config.initialDiscoveryRetryDelay) + } + } + } + throw OpenIdDiscoveryException( + "Failed to discover OpenID configuration after ${config.initialDiscoveryAttempts} attempt(s)", + lastFailure, + ) + } + + private fun Application.startRefreshingMetadata(provider: OidcProvider) { + if (!config.discoveryRefreshInterval.isPositive()) { + return + } + launch(Dispatchers.IO) { + var hasPreviousFailure = false + var consecutiveFailures = 0 + while (isActive) { + val duration = if (hasPreviousFailure) { + hasPreviousFailure = false + config.discoveryRefreshFailureDelay + } else { + config.discoveryRefreshInterval + } + delay(duration) + try { + val newMetadata = client.discoverVerified(provider.issuer) + provider.updateMetadata(newMetadata) + consecutiveFailures = 0 + } catch (cause: CancellationException) { + throw cause + } catch (cause: Throwable) { + consecutiveFailures++ + val event = OidcMetadataRefreshFailure(provider, consecutiveFailures, cause) + monitor.raiseCatching( + definition = OidcMetadataRefreshFailed, + value = event, + logger = provider.logger + ) + hasPreviousFailure = true + } + } + } + } + + public companion object : BaseApplicationPlugin { + override val key: AttributeKey = AttributeKey("OIDC") + + @OptIn(ExperimentalKtorApi::class) + override fun install( + pipeline: Application, + configure: OidcPluginConfig.() -> Unit + ): Oidc { + val config = OidcPluginConfig().apply { + pipeline.loadConfigFromEnvironment() + configure() + validate() + } + + val managedClient = config.httpClient ?: defaultOpenIdHttpClient() + if (config.httpClient == null) { + pipeline.monitor.subscribe(ApplicationStopped) { managedClient.close() } + } + + val plugin = Oidc( + application = pipeline, + config = config, + client = managedClient, + ) + + pipeline.monitor.subscribe(ApplicationModulesLoaded) { + if (plugin.providers.isEmpty()) { + pipeline.log.warn("No OpenID Connect issuers configured.") + } + } + + return plugin + } + } +} + +/** + * Details of a failed periodic OpenID Connect discovery metadata refresh. + * Routes and token validation continue with the last successful discovery document. + * + * @property provider OpenID Connect provider instance. + * @property consecutiveFailures number of consecutive periodic refresh failures, reset after a successful refresh. + * @property cause failure raised while fetching or validating discovery metadata. + */ +public class OidcMetadataRefreshFailure( + public val provider: OidcProvider, + public val consecutiveFailures: Int, + public val cause: Throwable +) { + public val causedByValidation: Boolean = cause is IllegalArgumentException +} + +/** + * Monitor event raised when a periodic OpenID Connect discovery metadata refresh fails. + * + * Subscribe to this event with [Application.monitor]. Initial discovery failures are reported through provider + * registration exceptions and do not raise this event. + */ +public val OidcMetadataRefreshFailed: EventDefinition = EventDefinition() + +/** + * Installs [Oidc] in this application and returns the provider registry. + * + * Use the returned [Oidc] to declare providers and keep provider configuration close to application setup. + * Provider registration is suspendable because it performs initial discovery. + * + * @param configure plugin configuration block. + * @return installed OpenID Connect provider registry. + * @throws IllegalArgumentException when environment-based provider configuration is invalid. + */ +public fun Application.openIdConnect( + configure: OidcPluginConfig.() -> Unit = {}, +): Oidc = + install(Oidc, configure) + +/** + * Returns the installed [Oidc] plugin instance. + * + * Convenience wrapper for `plugin(Oidc)`. + * + * @return installed OpenID Connect plugin instance. + * @throws MissingApplicationPluginException when [Oidc] is not installed. + */ +public fun Application.openIdConnect(): Oidc = plugin(Oidc) + +private fun defaultOpenIdHttpClient(): HttpClient = HttpClient { + install(ContentNegotiation) { + val format = Json { + ignoreUnknownKeys = true + isLenient = true + } + json(format) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt new file mode 100644 index 00000000000..18e3634ed4c --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.client.* +import io.ktor.server.application.* +import io.ktor.server.config.* +import io.ktor.utils.io.* +import kotlinx.serialization.Serializable +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Configuration for the [Oidc] plugin. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcPluginConfig) + */ +@KtorDsl +public class OidcPluginConfig { + internal val environmentProviders: MutableMap = linkedMapOf() + + /** + * Optional HTTP client used for OpenID Connect discovery requests. + * If not configured, the plugin installs an internal client. + */ + public var httpClient: HttpClient? = null + + /** + * Discovery refresh interval after a successful application startup. + * Set to `Duration.ZERO` to disable periodic refresh. + */ + public var discoveryRefreshInterval: Duration = 15.minutes + + /** + * Delay before the next periodic discovery refresh attempt after a failure. + * + * Successful refreshes use [discoveryRefreshInterval]. After a failed refresh, the next attempt uses this delay; + * a later successful refresh resets the schedule back to [discoveryRefreshInterval]. + */ + public var discoveryRefreshFailureDelay: Duration = 1.minutes + + /** + * Number of attempts for initial discovery during provider registration. + * + * Initial discovery blocks the suspend provider registration call until the provider has loaded metadata, or + * until this number of attempts is exhausted. If discovery still fails after the final attempt, registration + * fails with [DiscoveryException]. + */ + public var initialDiscoveryAttempts: Int = 1 + + /** + * Delay between failed initial discovery attempts during provider registration. + * + * The delay is applied only between attempts. It is not used after the final failed attempt. + */ + public var initialDiscoveryRetryDelay: Duration = 5.seconds + + /** + * Represents a single configured OpenID Connect provider loaded from the environment. + */ + @Serializable + internal data class EnvConfig( + val issuer: String, + ) + + internal fun Application.loadConfigFromEnvironment() { + if (environment.config.propertyOrNull("ktor.openid") == null) { + return + } + + val root = environment.config.config("ktor.openid") + root.keys().map { it.substringBefore(".") }.distinct().forEach { providerName -> + this@OidcPluginConfig.environmentProviders[providerName] = root.property(providerName).getAs() + } + } + + internal fun validate() { + require(initialDiscoveryAttempts >= 1) { + "initialDiscoveryAttempts must be greater than or equal to 1" + } + require(initialDiscoveryRetryDelay.isFinite() && !initialDiscoveryRetryDelay.isNegative()) { + "initialDiscoveryRetryDelay must be finite and non-negative" + } + } +} + +/** + * Configuration for a single OpenID Connect provider (issuer). + * + * @property name provider name used by this application. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcProviderConfig) + */ +@KtorDsl +public class OidcProviderConfig internal constructor( + public val name: String, +) { + /** + * Issuer URL. Used for OpenID Connect discovery (`/.well-known/openid-configuration`). + */ + public lateinit var issuer: String + + internal fun validate() { + require(::issuer.isInitialized && issuer.isNotBlank()) { + "issuer must be configured" + } + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcDiscovery.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcDiscovery.kt new file mode 100644 index 00000000000..71395d32d36 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcDiscovery.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.client.* + +internal suspend fun HttpClient.discoverVerified(issuer: String): OpenIdProviderMetadata = + fetchOpenIdMetadata(issuer).also { metadata -> + require(metadata.issuer == issuer) { + "OpenID issuer mismatch: expected exactly $issuer, got ${metadata.issuer}" + } + } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt new file mode 100644 index 00000000000..e5aef9ec15b --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.client.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Configured OpenID Connect provider. + * + * @property name provider name. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcProvider) + */ +public class OidcProvider internal constructor( + public val name: String, + internal val client: HttpClient, + internal val config: OidcProviderConfig, +) { + /** + * Issuer URL configured for this provider. + */ + public val issuer: String = config.issuer + + internal val logger: Logger = LoggerFactory.getLogger("io.ktor.server.auth.oidc.OidcProvider[$name]") + + @Volatile + private var providerState: OidcProviderState? = null + + internal fun updateMetadata(newMetadata: OpenIdProviderMetadata) { + providerState = OidcProviderState(newMetadata) + } + + /** + * Returns the currently active OpenID Connect discovery metadata for this provider. + * The returned value can change after a successful periodic discovery refresh. + * + * @throws IllegalStateException when metadata has not been initialized yet. + */ + public fun currentMetadata(): OpenIdProviderMetadata = + checkNotNull(providerState) { + "OpenID Connect metadata is not initialized for provider $name" + }.metadata +} + +private data class OidcProviderState( + val metadata: OpenIdProviderMetadata, +) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/FetchOpenIdProviderMetadataTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/FetchOpenIdProviderMetadataTest.kt index 012d2e69a21..66b4533800f 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/FetchOpenIdProviderMetadataTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/FetchOpenIdProviderMetadataTest.kt @@ -4,33 +4,24 @@ package io.ktor.server.auth.oidc -import io.ktor.client.* import io.ktor.http.* import io.ktor.http.auth.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* +import io.ktor.server.auth.oidc.utils.discoveryClient +import io.ktor.server.auth.oidc.utils.discoveryJson import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.testing.* -import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation class FetchOpenIdProviderMetadataTest { - private val discoveryJson = Json { ignoreUnknownKeys = true } - - private fun ApplicationTestBuilder.discoveryClient(): HttpClient = createClient { - install(ClientContentNegotiation) { - json(discoveryJson) - } - } - private fun TestApplicationBuilder.configService(config: OpenIdProviderMetadata) { externalServices { hosts("http://localhost") { diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcDiscoveryTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcDiscoveryTest.kt new file mode 100644 index 00000000000..5434eb5387d --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcDiscoveryTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.http.* +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.* +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class OidcDiscoveryTest { + + @Test + fun `provider uses refreshed discovery metadata`() = testApplication { + val discoveryRequests = AtomicInteger() + val allowRefreshResponse = CompletableDeferred() + + refreshingOpenIdProvider(discoveryRequests, allowRefreshResponse) + + val openIdClient = openIdHttpClient() + lateinit var provider: OidcProvider + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = 10.milliseconds + discoveryRefreshFailureDelay = 10.milliseconds + } + provider = oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + + startApplication() + assertEquals("$ISSUER_URL/authorize-initial", provider.currentMetadata().authorizationEndpoint) + + allowRefreshResponse.complete(Unit) + val refreshedAuthorizeEndpoint = withTimeout(5.seconds) { + while (true) { + val endpoint = provider.currentMetadata().authorizationEndpoint + if (endpoint.endsWith("/authorize-refreshed")) { + return@withTimeout endpoint + } + delay(10.milliseconds) + } + } + assertEquals("$ISSUER_URL/authorize-refreshed", refreshedAuthorizeEndpoint) + } + + @Test + fun `failed periodic refresh raises event and keeps stale metadata`() = testApplication { + val discoveryRequests = AtomicInteger() + val refreshFailed = CompletableDeferred() + + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + val requestNumber = discoveryRequests.incrementAndGet() + val metadata = if (requestNumber == 1) { + OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = "$ISSUER_URL/authorize-initial", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + ) + } else { + OpenIdProviderMetadata( + issuer = "$ISSUER_URL/", + authorizationEndpoint = "$ISSUER_URL/authorize-invalid", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + ) + } + call.respond(metadata) + } + } + } + } + + val openIdClient = openIdHttpClient() + lateinit var provider: OidcProvider + application { + monitor.subscribe(OidcMetadataRefreshFailed) { failure -> + refreshFailed.complete(failure) + } + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = 10.milliseconds + discoveryRefreshFailureDelay = 10.milliseconds + } + provider = oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + + startApplication() + assertEquals("$ISSUER_URL/authorize-initial", provider.currentMetadata().authorizationEndpoint) + + val failure = withTimeout(5.seconds) { refreshFailed.await() } + assertSame(provider, failure.provider) + assertEquals("auth0", failure.provider.name) + assertEquals(ISSUER_URL, failure.provider.issuer) + assertEquals("$ISSUER_URL/authorize-initial", provider.currentMetadata().authorizationEndpoint) + assertEquals(1, failure.consecutiveFailures) + assertIs(failure.cause) + assertContains(failure.cause.message.orEmpty(), "issuer mismatch") + } + + @Test + fun `initial discovery retries non issuer failure during registration`() = testApplication { + val discoveryRequests = AtomicInteger() + + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + if (discoveryRequests.incrementAndGet() == 1) { + call.respond(HttpStatusCode.ServiceUnavailable, "temporarily unavailable") + } else { + call.respond(openIdProviderMetadata) + } + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + initialDiscoveryAttempts = 2 + initialDiscoveryRetryDelay = ZERO + } + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + + startApplication() + assertEquals(2, discoveryRequests.get()) + } + + @Test + fun `initial discovery throws after configured attempts fail`() { + val discoveryRequests = AtomicInteger() + + val failure = assertFailsWith { + testApplication { + externalServices { + hosts(ISSUER_URL) { + routing { + get("/.well-known/openid-configuration") { + discoveryRequests.incrementAndGet() + call.respond(HttpStatusCode.ServiceUnavailable, "temporarily unavailable") + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + initialDiscoveryAttempts = 2 + initialDiscoveryRetryDelay = ZERO + } + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + startApplication() + } + } + + assertContains(failure.message.orEmpty(), "after 2 attempt") + assertEquals(2, discoveryRequests.get()) + } + + @Test + fun `failed initial discovery releases reserved provider name and issuer`() = testApplication { + val discoveryRequests = AtomicInteger() + + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + if (discoveryRequests.incrementAndGet() == 1) { + call.respond(HttpStatusCode.ServiceUnavailable, "temporarily unavailable") + } else { + call.respond(openIdProviderMetadata) + } + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + initialDiscoveryAttempts = 1 + initialDiscoveryRetryDelay = ZERO + } + assertFailsWith { + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + + startApplication() + assertEquals(2, discoveryRequests.get()) + } + + @Test + fun `initial discovery rejects non exact issuer without retrying`() { + val discoveryRequests = AtomicInteger() + + val failure = assertFailsWith { + testApplication { + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + discoveryRequests.incrementAndGet() + call.respond( + OpenIdProviderMetadata( + issuer = "$ISSUER_URL/", + authorizationEndpoint = "$ISSUER_URL/authorize", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + ) + ) + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + initialDiscoveryAttempts = 2 + initialDiscoveryRetryDelay = ZERO + } + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + startApplication() + } + } + + assertContains( + failure.message.orEmpty(), + "OpenID issuer mismatch: expected exactly $ISSUER_URL, got $ISSUER_URL/" + ) + assertEquals(1, discoveryRequests.get()) + } + + private fun TestApplicationBuilder.refreshingOpenIdProvider( + discoveryRequests: AtomicInteger, + allowRefreshResponse: CompletableDeferred, + ) { + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + refreshingDiscoveryEndpoint(discoveryRequests, allowRefreshResponse) + } + } + } + } + + private fun Route.refreshingDiscoveryEndpoint( + discoveryRequests: AtomicInteger, + allowRefreshResponse: CompletableDeferred, + ) { + get("/.well-known/openid-configuration") { + val requestNumber = discoveryRequests.incrementAndGet() + val authorizationEndpoint = if (requestNumber == 1) { + "$ISSUER_URL/authorize-initial" + } else { + allowRefreshResponse.await() + "$ISSUER_URL/authorize-refreshed" + } + + call.respond( + OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = authorizationEndpoint, + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + ) + ) + } + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt new file mode 100644 index 00000000000..e0eee1515d9 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.http.* +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.config.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.* +import kotlin.time.Duration.Companion.ZERO + +class OidcEnvironmentConfigTest { + + @Test + fun `environment provider is ignored until registered in code`() = testApplication { + val discoveryRequests = AtomicInteger() + + environment { + config = oidcEnvironmentConfig() + } + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + discoveryRequests.incrementAndGet() + call.respond(openIdProviderMetadata) + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + } + + startApplication() + assertEquals(0, discoveryRequests.get()) + } + + @Test + fun `environment provider issuer can be extended in code`() = testApplication { + environment { + config = oidcEnvironmentConfig() + } + openIdProvider() + + val openIdClient = openIdHttpClient() + lateinit var provider: OidcProvider + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + provider = oidc.provider("auth0") {} + } + + startApplication() + assertEquals(ISSUER_URL, provider.issuer) + assertEquals(openIdProviderMetadata.issuer, provider.currentMetadata().issuer) + assertEquals(openIdProviderMetadata.authorizationEndpoint, provider.currentMetadata().authorizationEndpoint) + } + + @Test + fun `failed provider configuration does not consume environment provider config`() = testApplication { + val discoveryRequests = AtomicInteger() + + environment { + config = oidcEnvironmentConfig() + } + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + discoveryRequests.incrementAndGet() + call.respond(openIdProviderMetadata) + } + } + } + } + + val openIdClient = openIdHttpClient() + lateinit var provider: OidcProvider + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + + val failure = assertFailsWith { + oidc.provider("auth0") { + issuer = " " + } + } + assertContains(failure.message.orEmpty(), "issuer") + + provider = oidc.provider("auth0") {} + } + + startApplication() + assertEquals(ISSUER_URL, provider.issuer) + assertEquals(1, discoveryRequests.get()) + } + + private fun oidcEnvironmentConfig( + providerName: String = "auth0", + ): MapApplicationConfig = + MapApplicationConfig("ktor.openid.$providerName.issuer" to ISSUER_URL) +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt new file mode 100644 index 00000000000..a0d5fc32a06 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.* +import kotlin.time.Duration.Companion.ZERO + +class OidcPluginRegistrationTest { + + @Test + fun `plugin and provider helpers expose expected state`() = testApplication { + val provider = OidcProvider( + name = "auth0", + client = client, + config = OidcProviderConfig("auth0").apply { + issuer = ISSUER_URL + }, + ) + + provider.updateMetadata(openIdProviderMetadata) + assertEquals(openIdProviderMetadata, provider.currentMetadata()) + + val updatedMetadata = OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = "$ISSUER_URL/authorize-updated", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + ) + provider.updateMetadata(updatedMetadata) + assertEquals(updatedMetadata, provider.currentMetadata()) + + application { + val installed: Oidc = openIdConnect { } + assertSame(installed, openIdConnect()) + } + startApplication() + } + + @Test + fun `concurrent provider registration is synchronized`() { + assertConcurrentDuplicateRegistrations( + providerNames = List(16) { "auth0" }, + expectedFailureMessage = "already configured", + ) + + assertConcurrentDuplicateRegistrations( + providerNames = List(16) { index -> "auth0-$index" }, + expectedFailureMessage = "Duplicate OIDC issuer", + ) + + assertConcurrentDistinctRegistrations() + } + + @Test + fun `provider registration validates names and duplicate providers`() { + val invalidNames = listOf("Google", "google_auth", "-google", "google-", "google--auth") + invalidNames.forEach { providerName -> + val failure = assertFailsWith { + testApplication { + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + oidc.provider(providerName) { + issuer = ISSUER_URL + } + } + startApplication() + } + } + assertContains(failure.message.orEmpty(), "provider name") + } + + testApplication { + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + oidc.provider("auth0") { + issuer = ISSUER_URL + } + + val failure = assertFailsWith { + oidc.provider("auth0") { + issuer = ISSUER_URL + } + } + assertContains(failure.message.orEmpty(), "already configured") + } + } + } + + private fun assertConcurrentDuplicateRegistrations( + providerNames: List, + expectedFailureMessage: String, + ) = testApplication { + val discoveryRequests = AtomicInteger() + + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + get("/.well-known/openid-configuration") { + discoveryRequests.incrementAndGet() + call.respond(openIdProviderMetadata) + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + + val results = coroutineScope { + providerNames.map { providerName -> + async { + runCatching { + oidc.provider(providerName) { + issuer = ISSUER_URL + } + } + } + }.awaitAll() + } + + assertEquals(1, results.count { it.isSuccess }) + val failures = results.mapNotNull { it.exceptionOrNull() } + assertEquals(providerNames.size - 1, failures.size) + failures.forEach { failure -> + assertIs(failure) + assertContains(failure.message.orEmpty(), expectedFailureMessage) + } + } + + startApplication() + assertEquals(1, discoveryRequests.get()) + } + + private fun assertConcurrentDistinctRegistrations() = testApplication { + val issuers = listOf( + "auth0" to ISSUER_URL, + "okta" to "https://okta.example.com", + "keycloak" to "https://keycloak.example.com", + ) + issuers.forEach { (_, issuer) -> openIdProvider(issuer) } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + + val providers = coroutineScope { + issuers.map { (name, issuer) -> + async { + oidc.provider(name) { + this.issuer = issuer + } + } + }.awaitAll() + } + + assertEquals(issuers.map { it.first }.toSet(), providers.map { it.name }.toSet()) + } + + startApplication() + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt new file mode 100644 index 00000000000..ee9c6fe61ae --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc.utils + +import io.ktor.server.auth.oidc.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* + +internal val openIdProviderMetadata: OpenIdProviderMetadata = OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = "$ISSUER_URL/authorize", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", +) + +internal fun metadataForIssuer(issuer: String): OpenIdProviderMetadata = OpenIdProviderMetadata( + issuer = issuer, + authorizationEndpoint = "$issuer/authorize", + tokenEndpoint = "$issuer/token", + jwksUri = "$issuer/jwks", +) + +internal fun TestApplicationBuilder.openIdProvider( + metadata: OpenIdProviderMetadata = openIdProviderMetadata +) { + externalServices { + hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() + routing { + openIdDiscoveryEndpoint(metadata) + } + } + } +} + +internal fun TestApplicationBuilder.openIdProvider( + issuer: String, + metadata: OpenIdProviderMetadata = metadataForIssuer(issuer), +) { + externalServices { + hosts(issuer) { + installDiscoveryContentNegotiation() + routing { + openIdDiscoveryEndpoint(metadata) + } + } + } +} + +internal fun Route.openIdDiscoveryEndpoint(metadata: OpenIdProviderMetadata = openIdProviderMetadata) { + get("/.well-known/openid-configuration") { + call.respond(metadata) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestUtils.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestUtils.kt new file mode 100644 index 00000000000..8997e038dfb --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc.utils + +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation + +internal const val ISSUER_URL: String = "https://auth0.example.com" + +internal val discoveryJson = Json { ignoreUnknownKeys = true } + +internal fun ApplicationTestBuilder.noRedirectsClient(): HttpClient = createClient { followRedirects = false } + +internal fun ApplicationTestBuilder.discoveryClient(): HttpClient = createClient { + install(ContentNegotiation) { + json(discoveryJson) + } +} + +internal fun ApplicationTestBuilder.openIdHttpClient(): HttpClient = discoveryClient() + +internal fun Application.installDiscoveryContentNegotiation() { + install(ServerContentNegotiation) { + json(discoveryJson) + } +}