Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ public final class io/ktor/server/auth/oidc/OidcPluginConfig {
public final fun getHttpClient ()Lio/ktor/client/HttpClient;
public final fun getInitialDiscoveryAttempts ()I
public final fun getInitialDiscoveryRetryDelay-UwyO8pc ()J
public final fun protectedResource (Ljava/lang/String;)V
public final fun protectedResource (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public final fun setDiscoveryRefreshFailureDelay-LRDsOJo (J)V
public final fun setDiscoveryRefreshInterval-LRDsOJo (J)V
public final fun setHttpClient (Lio/ktor/client/HttpClient;)V
Expand Down Expand Up @@ -502,6 +504,71 @@ public abstract class io/ktor/server/auth/oidc/OpenIdTestTokenBuilder {
public final fun setSubject (Ljava/lang/String;)V
}

public final class io/ktor/server/auth/oidc/ProtectedResourceMetadata {
public static final field Companion Lio/ktor/server/auth/oidc/ProtectedResourceMetadata$Companion;
public fun <init> (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/util/List;Ljava/util/List;Ljava/lang/Boolean;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/util/List;Ljava/util/List;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getAuthorizationDetailsTypesSupported ()Ljava/util/List;
public final fun getAuthorizationServers ()Ljava/util/List;
public final fun getBearerMethodsSupported ()Ljava/util/List;
public final fun getDpopBoundAccessTokensRequired ()Ljava/lang/Boolean;
public final fun getDpopSigningAlgValuesSupported ()Ljava/util/List;
public final fun getJwksUri ()Ljava/lang/String;
public final fun getResource ()Ljava/lang/String;
public final fun getResourceDocumentation ()Ljava/lang/String;
public final fun getResourceName ()Ljava/lang/String;
public final fun getResourcePolicyUri ()Ljava/lang/String;
public final fun getResourceSigningAlgValuesSupported ()Ljava/util/List;
public final fun getResourceTosUri ()Ljava/lang/String;
public final fun getScopesSupported ()Ljava/util/List;
public final fun getTlsClientCertificateBoundAccessTokens ()Ljava/lang/Boolean;
}

public final synthetic class io/ktor/server/auth/oidc/ProtectedResourceMetadata$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
public static final field INSTANCE Lio/ktor/server/auth/oidc/ProtectedResourceMetadata$$serializer;
public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/ProtectedResourceMetadata;
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/ktor/server/auth/oidc/ProtectedResourceMetadata;)V
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
}

public final class io/ktor/server/auth/oidc/ProtectedResourceMetadata$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class io/ktor/server/auth/oidc/ProtectedResourceMetadataConfig {
public final fun getAuthorizationDetailsTypesSupported ()Ljava/util/List;
public final fun getAuthorizationServers ()Ljava/util/List;
public final fun getBearerMethodsSupported ()Ljava/util/List;
public final fun getDpopBoundAccessTokensRequired ()Ljava/lang/Boolean;
public final fun getDpopSigningAlgValuesSupported ()Ljava/util/List;
public final fun getJwksUri ()Ljava/lang/String;
public final fun getResource ()Ljava/lang/String;
public final fun getResourceDocumentation ()Ljava/lang/String;
public final fun getResourceName ()Ljava/lang/String;
public final fun getResourcePolicyUri ()Ljava/lang/String;
public final fun getResourceSigningAlgValuesSupported ()Ljava/util/List;
public final fun getResourceTosUri ()Ljava/lang/String;
public final fun getScopesSupported ()Ljava/util/List;
public final fun getTlsClientCertificateBoundAccessTokens ()Ljava/lang/Boolean;
public final fun setAuthorizationDetailsTypesSupported (Ljava/util/List;)V
public final fun setAuthorizationServers (Ljava/util/List;)V
public final fun setBearerMethodsSupported (Ljava/util/List;)V
public final fun setDpopBoundAccessTokensRequired (Ljava/lang/Boolean;)V
public final fun setDpopSigningAlgValuesSupported (Ljava/util/List;)V
public final fun setJwksUri (Ljava/lang/String;)V
public final fun setResourceDocumentation (Ljava/lang/String;)V
public final fun setResourceName (Ljava/lang/String;)V
public final fun setResourcePolicyUri (Ljava/lang/String;)V
public final fun setResourceSigningAlgValuesSupported (Ljava/util/List;)V
public final fun setResourceTosUri (Ljava/lang/String;)V
public final fun setScopesSupported (Ljava/util/List;)V
public final fun setTlsClientCertificateBoundAccessTokens (Ljava/lang/Boolean;)V
}

public final class io/ktor/server/auth/oidc/TokenClaims {
public final fun claim (Ljava/lang/String;)Lkotlinx/serialization/json/JsonElement;
public final fun claimString (Ljava/lang/String;)Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ kotlin {
api(projects.ktorServerAuthJwt)
api(projects.ktorClientCore)
api(projects.ktorClientContentNegotiation)
api(projects.ktorServerContentNegotiation)
api(projects.ktorSerializationKotlinxJson)
api(libs.kotlinx.serialization.json)
}
jvmTest.dependencies {
implementation(projects.ktorServerTestHost)
implementation(projects.ktorServerContentNegotiation)
implementation(projects.ktorServerCio)
implementation(projects.ktorClientMock)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*")
* Registered internally as `"$name-oauth"` and used only for the auto-registered routes;
* browser session authentication is exposed as [OidcProvider.sessions].
*
* This plugin implements the Authorization Code Flow with PKCE (RFC 6749 §4.1, OIDC Core §3.1) and resource-server
* Bearer / RFC 7662 introspection. Implicit and Hybrid flows are not supported.
* This plugin implements the Authorization Code Flow with PKCE (RFC 6749 §4.1, OIDC Core §3.1), resource-server
* Bearer / RFC 7662 introspection, and optional OAuth 2.0 Protected Resource Metadata (RFC 9728) via
* [OidcPluginConfig.protectedResource]. Implicit and Hybrid flows are not supported.
*
* Provider metadata is fetched automatically from the issuer's discovery document
* (`<issuer>/.well-known/openid-configuration`) and periodically refreshed unless a provider configures static
Expand Down Expand Up @@ -297,8 +298,25 @@ public class Oidc internal constructor(
return provider
}

internal fun configureProtectedResourceRoute() {
config.protectedResourceConfig?.let { protectedResourceConfig ->
application.configureProtectedResourceRoute(protectedResourceConfig) {
providers.values.map { it.config }
}
}
}

private fun resourceMetadataUrl(): String? =
config.protectedResourceConfig?.let { protectedResourceConfig ->
require(protectedResourceConfig.resource.isNotBlank()) {
"protectedResource(resource) must be set to the resource server's identifier URL"
}
buildResourceMetadataUrl(protectedResourceConfig.resource)
}

private suspend fun commitProvider(provider: OidcProvider<*>) = providerRegistrationMutex.withLock {
checkProductionEnvironment(provider)
provider.resourceMetadataUrl = resourceMetadataUrl()
if (provider.config.oauthConfig != null) {
application.configureOAuthRoute(provider)
}
Expand Down Expand Up @@ -442,6 +460,7 @@ public class Oidc internal constructor(
client = managedClient,
)
plugin.loadConfigFromEnvironment()
plugin.configureProtectedResourceRoute()

pipeline.monitor.subscribe(ApplicationModulesLoaded) {
if (plugin.providers.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import org.slf4j.Logger
private const val AuthorizationHeaderLogLimit: Int = 96

@OptIn(InternalAPI::class)
internal fun <P : Any> OidcProvider<P>.createBearerScheme(): OidcBearerScheme<P> {
internal fun <P : Any> OidcProvider<P>.createBearerScheme(
resourceMetadataUrl: String?,
): OidcBearerScheme<P> {
val extractor = bearerConfig.tokenExtractor
return bearer(
name = "$name-bearer",
Expand All @@ -45,9 +47,10 @@ internal fun <P : Any> OidcProvider<P>.createBearerScheme(): OidcBearerScheme<P>
}

onUnauthorized = { _ ->
val parameters = resourceMetadataUrl?.let { mapOf("resource_metadata" to it) }.orEmpty()
val challenge = HttpAuthHeader.Parameterized(
authScheme = AuthScheme.Bearer,
parameters = emptyMap(),
parameters = parameters,
)
call.respond(UnauthorizedResponse(challenge))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import kotlin.time.Duration.Companion.seconds
*/
@KtorDsl
public class OidcPluginConfig {
internal var protectedResourceConfig: ProtectedResourceMetadataConfig? = null

/**
* Optional HTTP client used for discovery and userinfo requests.
* If not configured, the plugin installs an internal client.
Expand Down Expand Up @@ -67,6 +69,28 @@ public class OidcPluginConfig {
*/
public var initialDiscoveryRetryDelay: Duration = 5.seconds

/**
* Configures OAuth 2.0 Protected Resource Metadata (RFC 9728) with defaults.
*
* When configured, the plugin serves a `/.well-known/oauth-protected-resource` endpoint with
* metadata for this resource and includes a `resource_metadata` parameter in `WWW-Authenticate`
* headers on Bearer authentication failures.
*/
public fun protectedResource(resource: String) {
protectedResource(resource) {}
}

/**
* Configures OAuth 2.0 Protected Resource Metadata (RFC 9728).
*
* When configured, the plugin serves a `/.well-known/oauth-protected-resource` endpoint with
* metadata for this resource and includes a `resource_metadata` parameter in `WWW-Authenticate`
* headers on Bearer authentication failures.
*/
public fun protectedResource(resource: String, configure: ProtectedResourceMetadataConfig.() -> Unit) {
protectedResourceConfig = ProtectedResourceMetadataConfig(resource).apply(configure)
}

internal fun validate() {
require(initialDiscoveryAttempts >= 1) {
"initialDiscoveryAttempts must be greater than or equal to 1"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
import java.net.URI

internal fun Application.configureProtectedResourceRoute(
config: ProtectedResourceMetadataConfig,
providers: () -> List<OidcProviderConfig<*>>,
) = routing {
install(ContentNegotiation) {
val format = Json {
explicitNulls = false
encodeDefaults = true
}
json(format)
}

val metadata by lazy { buildProtectedResourceMetadata(config, providers()) }
val path = buildResourceMetadataRoutePath(config.resource)
get(path) { call.respond(metadata) }
}

internal fun buildProtectedResourceMetadata(
config: ProtectedResourceMetadataConfig,
providers: Collection<OidcProviderConfig<*>>,
): ProtectedResourceMetadata {
val authorizationServers = config.authorizationServers
?: providers.map { it.issuer }.distinct().ifEmpty { null }

val scopesSupported = config.scopesSupported
?: providers
.mapNotNull { it.oauthConfig?.scopes }
.flatten()
.distinct()
.ifEmpty { null }

val bearerMethodsSupported = config.bearerMethodsSupported
?: listOf("header").takeIf {
providers.any { provider ->
val bearerConfig = provider.bearerConfig ?: return@any false
bearerConfig.tokenExtractor == null
}
}

return ProtectedResourceMetadata(
resource = config.resource,
authorizationServers = authorizationServers,
jwksUri = config.jwksUri,
scopesSupported = scopesSupported,
bearerMethodsSupported = bearerMethodsSupported,
resourceSigningAlgValuesSupported = config.resourceSigningAlgValuesSupported,
resourceName = config.resourceName,
resourceDocumentation = config.resourceDocumentation,
resourcePolicyUri = config.resourcePolicyUri,
resourceTosUri = config.resourceTosUri,
tlsClientCertificateBoundAccessTokens = config.tlsClientCertificateBoundAccessTokens,
authorizationDetailsTypesSupported = config.authorizationDetailsTypesSupported,
dpopSigningAlgValuesSupported = config.dpopSigningAlgValuesSupported,
dpopBoundAccessTokensRequired = config.dpopBoundAccessTokensRequired,
)
}

internal fun buildResourceMetadataUrl(resource: String): String {
val uri = parseProtectedResourceUri(resource)
val port = if (uri.port == -1) "" else ":${uri.port}"
val resourcePath = uri.path?.trimEnd('/') ?: ""
return "${uri.scheme}://${uri.host}$port/.well-known/oauth-protected-resource$resourcePath"
}

private fun buildResourceMetadataRoutePath(resource: String): String {
val resourcePath = parseProtectedResourceUri(resource).path?.trimEnd('/') ?: ""
return "/.well-known/oauth-protected-resource$resourcePath"
}

private fun parseProtectedResourceUri(resource: String): URI = URI(resource).apply {
require(scheme?.equals("https", ignoreCase = true) == true) {
"protectedResource(resource) must use the https scheme: $resource"
}
require(!host.isNullOrBlank()) { "protectedResource(resource) must include a host: $resource" }
require(rawUserInfo == null) { "protectedResource(resource) must not include userinfo: $resource" }
require(rawQuery == null) { "protectedResource(resource) must not include a query: $resource" }
require(rawFragment == null) { "protectedResource(resource) must not include a fragment: $resource" }
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public class OidcProvider<P : Any> internal constructor(
internal val accessTokenConfig: OidcAccessTokenConfig
get() = checkNotNull(config.accessTokenConfig) { "Access token is not enabled for provider $name" }

internal var resourceMetadataUrl: String? = null

internal val bearerConfig: OidcBearerConfig
get() = checkNotNull(config.bearerConfig) {
"Bearer scheme is not enabled. Call bearer { } in the provider $name."
Expand Down Expand Up @@ -214,7 +216,7 @@ public class OidcProvider<P : Any> internal constructor(
*
* @throws IllegalStateException when the provider was not configured with `bearer { }`.
*/
public val bearer: OidcBearerScheme<P> by lazy { createBearerScheme() }
public val bearer: OidcBearerScheme<P> by lazy { createBearerScheme(resourceMetadataUrl) }

/**
* Typed browser session authentication scheme.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* OAuth 2.0 Protected Resource Metadata as defined in RFC 9728.
*
* Served at the `/.well-known/oauth-protected-resource` endpoint when
* [OidcPluginConfig.protectedResource] is configured.
*
* @property resource The protected resource's identifier URL.
* @property authorizationServers OAuth authorization server issuer identifiers trusted by this resource.
* @property jwksUri URL of the resource server's JWK Set document containing its public keys.
* @property scopesSupported OAuth 2.0 scope values that this resource server understands.
* @property bearerMethodsSupported Methods supported for presenting Bearer tokens: `header`, `body`, `query`.
* @property resourceSigningAlgValuesSupported JWS algorithms supported by this resource, excluding `none`.
* @property resourceName Human-readable name of the protected resource.
* @property resourceDocumentation URL of developer documentation for this resource.
* @property resourcePolicyUri URL describing the resource's data usage requirements.
* @property resourceTosUri URL of the resource's terms of service.
* @property tlsClientCertificateBoundAccessTokens Whether this resource requires TLS client certificate-bound
* access tokens.
* @property authorizationDetailsTypesSupported Authorization details types supported per RFC 9396.
* @property dpopSigningAlgValuesSupported JWS algorithms supported for DPoP proof validation.
* @property dpopBoundAccessTokensRequired Whether DPoP-bound access tokens are required.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.ProtectedResourceMetadata)
*/
@Serializable
public class ProtectedResourceMetadata(
public val resource: String,
@SerialName("authorization_servers")
public val authorizationServers: List<String>? = null,
@SerialName("jwks_uri")
public val jwksUri: String? = null,
@SerialName("scopes_supported")
public val scopesSupported: List<String>? = null,
@SerialName("bearer_methods_supported")
public val bearerMethodsSupported: List<String>? = null,
@SerialName("resource_signing_alg_values_supported")
public val resourceSigningAlgValuesSupported: List<String>? = null,
@SerialName("resource_name")
public val resourceName: String? = null,
@SerialName("resource_documentation")
public val resourceDocumentation: String? = null,
@SerialName("resource_policy_uri")
public val resourcePolicyUri: String? = null,
@SerialName("resource_tos_uri")
public val resourceTosUri: String? = null,
@SerialName("tls_client_certificate_bound_access_tokens")
public val tlsClientCertificateBoundAccessTokens: Boolean? = null,
@SerialName("authorization_details_types_supported")
public val authorizationDetailsTypesSupported: List<String>? = null,
@SerialName("dpop_signing_alg_values_supported")
public val dpopSigningAlgValuesSupported: List<String>? = null,
@SerialName("dpop_bound_access_tokens_required")
public val dpopBoundAccessTokensRequired: Boolean? = null,
)
Loading
Loading