From 4070e4da733c174d55646af3fd29c300c1b734a4 Mon Sep 17 00:00:00 2001 From: zibet27 Date: Thu, 11 Jun 2026 16:57:23 +0200 Subject: [PATCH] RFC 9728 Protected Resource Metadata --- .../api/ktor-server-auth-oidc.api | 67 ++++ .../ktor-server-auth-oidc/build.gradle.kts | 2 +- .../jvm/src/io/ktor/server/auth/oidc/Oidc.kt | 23 +- .../io/ktor/server/auth/oidc/OidcBearer.kt | 7 +- .../io/ktor/server/auth/oidc/OidcConfig.kt | 24 ++ .../server/auth/oidc/OidcProtectedResource.kt | 92 +++++ .../io/ktor/server/auth/oidc/OidcProvider.kt | 4 +- .../auth/oidc/ProtectedResourceMetadata.kt | 63 ++++ .../oidc/ProtectedResourceMetadataConfig.kt | 100 +++++ .../oidc/ProtectedResourceMetadataTest.kt | 350 ++++++++++++++++++ 10 files changed, 726 insertions(+), 6 deletions(-) create mode 100644 ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProtectedResource.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadata.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadataConfig.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/ProtectedResourceMetadataTest.kt 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 462bb391fa6..6e2bf52b591 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 @@ -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 @@ -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 (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 (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; 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 9fff2a9768b..c9e6fad93b7 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 @@ -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) } 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 index a78930283d5..6f2575603fc 100644 --- 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 @@ -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 * (`/.well-known/openid-configuration`) and periodically refreshed unless a provider configures static @@ -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) } @@ -442,6 +460,7 @@ public class Oidc internal constructor( client = managedClient, ) plugin.loadConfigFromEnvironment() + plugin.configureProtectedResourceRoute() pipeline.monitor.subscribe(ApplicationModulesLoaded) { if (plugin.providers.isEmpty()) { diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt index 0d50b31bac1..97dd4b2587b 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt @@ -23,7 +23,9 @@ import org.slf4j.Logger private const val AuthorizationHeaderLogLimit: Int = 96 @OptIn(InternalAPI::class) -internal fun

OidcProvider

.createBearerScheme(): OidcBearerScheme

{ +internal fun

OidcProvider

.createBearerScheme( + resourceMetadataUrl: String?, +): OidcBearerScheme

{ val extractor = bearerConfig.tokenExtractor return bearer( name = "$name-bearer", @@ -45,9 +47,10 @@ internal fun

OidcProvider

.createBearerScheme(): OidcBearerScheme

} 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)) } 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 index 920fd1b00c4..77d0b025bd8 100644 --- 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 @@ -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. @@ -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" diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProtectedResource.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProtectedResource.kt new file mode 100644 index 00000000000..a2e215fc1af --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProtectedResource.kt @@ -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>, +) = 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>, +): 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" } +} 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 index 2154f7d8a1a..c1f6253d195 100644 --- 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 @@ -59,6 +59,8 @@ public class OidcProvider

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." @@ -214,7 +216,7 @@ public class OidcProvider

internal constructor( * * @throws IllegalStateException when the provider was not configured with `bearer { }`. */ - public val bearer: OidcBearerScheme

by lazy { createBearerScheme() } + public val bearer: OidcBearerScheme

by lazy { createBearerScheme(resourceMetadataUrl) } /** * Typed browser session authentication scheme. diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadata.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadata.kt new file mode 100644 index 00000000000..a4712d36d3c --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadata.kt @@ -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? = null, + @SerialName("jwks_uri") + public val jwksUri: String? = null, + @SerialName("scopes_supported") + public val scopesSupported: List? = null, + @SerialName("bearer_methods_supported") + public val bearerMethodsSupported: List? = null, + @SerialName("resource_signing_alg_values_supported") + public val resourceSigningAlgValuesSupported: List? = 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? = null, + @SerialName("dpop_signing_alg_values_supported") + public val dpopSigningAlgValuesSupported: List? = null, + @SerialName("dpop_bound_access_tokens_required") + public val dpopBoundAccessTokensRequired: Boolean? = null, +) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadataConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadataConfig.kt new file mode 100644 index 00000000000..16d549a1fff --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/ProtectedResourceMetadataConfig.kt @@ -0,0 +1,100 @@ +/* + * 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.utils.io.* + +/** + * Configuration for OAuth 2.0 Protected Resource Metadata (RFC 9728). + * + * Fields that can be auto-derived from provider configuration, such as [authorizationServers], + * [scopesSupported], and [bearerMethodsSupported], are populated automatically unless explicitly overridden. + * + * @param resource The protected resource's identifier URL. It must be an HTTPS URL with a host, + * no userinfo, no query, and no fragment. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.ProtectedResourceMetadataConfig) + */ +@KtorDsl +public class ProtectedResourceMetadataConfig internal constructor( + public val resource: String, +) { + /** + * OAuth authorization server issuer identifiers trusted by this resource. + * + * When `null`, auto-derived from all configured provider issuers. + */ + public var authorizationServers: List? = null + + /** + * URL of the resource server's JWK Set document. + * + * When `null`, omitted from the metadata response. + */ + public var jwksUri: String? = null + + /** + * OAuth 2.0 scope values that this resource server understands. + * + * When `null`, auto-derived from the union of all provider OAuth scopes. + */ + public var scopesSupported: List? = null + + /** + * Methods supported for presenting Bearer tokens: `header`, `body`, `query`. + * + * When `null`, auto-derived as `header` when at least one provider uses the default + * `Authorization: Bearer` header extractor. Custom token extractors cannot be inferred; set this value + * explicitly when a custom extractor reads tokens from another RFC 6750 location. + */ + public var bearerMethodsSupported: List? = null + + /** + * JWS algorithms supported by this resource server, excluding `none`. + * + * When `null`, omitted from the metadata response. + */ + public var resourceSigningAlgValuesSupported: List? = null + + /** + * Human-readable name of the protected resource. + */ + public var resourceName: String? = null + + /** + * URL of developer documentation for this resource. + */ + public var resourceDocumentation: String? = null + + /** + * URL describing the resource's data usage requirements. + */ + public var resourcePolicyUri: String? = null + + /** + * URL of the resource's terms of service. + */ + public var resourceTosUri: String? = null + + /** + * Whether this resource requires TLS client certificate-bound access tokens. + */ + public var tlsClientCertificateBoundAccessTokens: Boolean? = null + + /** + * Authorization details types supported per RFC 9396. + */ + public var authorizationDetailsTypesSupported: List? = null + + /** + * JWS algorithms supported for DPoP proof validation. + */ + public var dpopSigningAlgValuesSupported: List? = null + + /** + * Whether this resource requires DPoP-bound access tokens. + */ + public var dpopBoundAccessTokensRequired: Boolean? = null +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/ProtectedResourceMetadataTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/ProtectedResourceMetadataTest.kt new file mode 100644 index 00000000000..21af5a6ee36 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/ProtectedResourceMetadataTest.kt @@ -0,0 +1,350 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalKtorApi::class) + +package io.ktor.server.auth.oidc + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.auth.typesafe.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.ktor.utils.io.* +import kotlin.test.* + +class ProtectedResourceMetadataTest { + + @Test + fun `protected resource metadata endpoint returns correct JSON`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") { + resourceName = "My API" + } + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val response = client.get("/.well-known/oauth-protected-resource") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(ContentType.Application.Json, response.contentType()?.withoutParameters()) + val body = response.bodyAsText() + val metadata = discoveryJson.decodeFromString(body) + assertEquals("https://api.example.com", metadata.resource) + assertEquals(listOf(ISSUER_URL), metadata.authorizationServers) + assertEquals("My API", metadata.resourceName) + } + + @Test + fun `protected resource metadata can be enabled with defaults`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val metadata = discoveryJson.decodeFromString( + client.get("/.well-known/oauth-protected-resource").bodyAsText() + ) + assertEquals("https://api.example.com", metadata.resource) + assertEquals(listOf("header"), metadata.bearerMethodsSupported) + } + + @Test + fun `protected resource metadata supports explicit scopes`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") { + scopesSupported = listOf("openid", "profile", "custom-scope") + } + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val response = client.get("/.well-known/oauth-protected-resource") + val metadata = discoveryJson.decodeFromString(response.bodyAsText()) + assertEquals(listOf("openid", "profile", "custom-scope"), metadata.scopesSupported) + } + + @Test + fun `protected resource metadata explicit overrides take precedence`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") { + authorizationServers = listOf("https://custom-as.example.com") + scopesSupported = listOf("read", "write") + } + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val response = client.get("/.well-known/oauth-protected-resource") + val metadata = discoveryJson.decodeFromString(response.bodyAsText()) + assertEquals(listOf("https://custom-as.example.com"), metadata.authorizationServers) + assertEquals(listOf("read", "write"), metadata.scopesSupported) + } + + @Test + fun `protected resource metadata disabled by default`() = testApplication { + application { + val oidc = openIdConnect { } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val response = client.get("/.well-known/oauth-protected-resource") + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `WWW-Authenticate includes resource_metadata when protected resource configured`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") {} + } + val provider = oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("my-api") + } + bearer() + } + + routing { + authenticateWith(provider.bearer) { + get("/protected") { call.respondText("ok") } + } + } + } + val response = client.get("/protected") + assertEquals(HttpStatusCode.Unauthorized, response.status) + val wwwAuth = response.headers[HttpHeaders.WWWAuthenticate] + assertNotNull(wwwAuth) + assertContains(wwwAuth, "resource_metadata=") + assertContains(wwwAuth, "https://api.example.com/.well-known/oauth-protected-resource") + } + + @Test + fun `WWW-Authenticate omits resource_metadata when protected resource not configured`() = testApplication { + application { + val oidc = openIdConnect { } + val provider = oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("my-api") + } + bearer() + } + routing { + authenticateWith(provider.bearer) { + get("/protected") { call.respondText("ok") } + } + } + } + val response = client.get("/protected") + assertEquals(HttpStatusCode.Unauthorized, response.status) + val wwwAuth = response.headers[HttpHeaders.WWWAuthenticate] ?: "" + assertFalse(wwwAuth.contains("resource_metadata")) + } + + @Test + fun `protected resource metadata routes follow resource path and port`() { + val cases = listOf( + "https://api.example.com" to "/.well-known/oauth-protected-resource", + "https://api.example.com/v1/" to "/.well-known/oauth-protected-resource/v1", + "https://api.example.com/v1" to "/.well-known/oauth-protected-resource/v1", + "https://api.example.com:8443/v1" to "/.well-known/oauth-protected-resource/v1", + ) + + cases.forEach { (resource, routePath) -> + testApplication { + configureProtectedResourceApplication(resource, protectedPath = "/protected") + + val response = client.get(routePath) + assertEquals(HttpStatusCode.OK, response.status, resource) + val metadata = discoveryJson.decodeFromString(response.bodyAsText()) + assertEquals(resource, metadata.resource) + + val expectedMetadataUrl = buildResourceMetadataUrl(resource) + assertContains(expectedMetadataUrl, routePath) + val wwwAuth = client.get("/protected").headers[HttpHeaders.WWWAuthenticate] + assertNotNull(wwwAuth) + assertContains(wwwAuth, expectedMetadataUrl) + + if (routePath != "/.well-known/oauth-protected-resource") { + assertEquals(HttpStatusCode.NotFound, client.get("/.well-known/oauth-protected-resource").status) + } + } + } + } + + @Test + fun `protected resource metadata omits null fields`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") {} + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val body = client.get("/.well-known/oauth-protected-resource").bodyAsText() + assertFalse(body.contains("\"jwks_uri\"")) + assertFalse(body.contains("\"resource_name\"")) + assertFalse(body.contains("\"dpop_bound_access_tokens_required\"")) + assertContains(body, "\"resource\"") + assertContains(body, "\"authorization_servers\"") + } + + @Test + fun `protected resource metadata does not infer custom bearer extractors`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") {} + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer { + tokenExtractor = { call -> call.request.headers["X-Token"] } + } + } + } + val metadata = discoveryJson.decodeFromString( + client.get("/.well-known/oauth-protected-resource").bodyAsText() + ) + assertNull(metadata.bearerMethodsSupported) + } + + @Test + fun `protected resource metadata uses explicit bearer methods with custom extractor`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") { + bearerMethodsSupported = listOf("body") + } + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer { + tokenExtractor = { call -> call.request.headers["X-Token"] } + } + } + } + val metadata = discoveryJson.decodeFromString( + client.get("/.well-known/oauth-protected-resource").bodyAsText() + ) + assertEquals(listOf("body"), metadata.bearerMethodsSupported) + } + + @Test + fun `protected resource metadata derives bearer methods from token sources`() = testApplication { + application { + val oidc = openIdConnect { + protectedResource("https://api.example.com") {} + } + oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + } + val metadata = discoveryJson.decodeFromString( + client.get("/.well-known/oauth-protected-resource").bodyAsText() + ) + assertEquals(listOf("header"), metadata.bearerMethodsSupported) + } + + @Test + fun `protected resource metadata rejects unsupported resource URLs`() { + val invalidResources = listOf( + "https://user@api.example.com" to "userinfo", + "https://api.example.com?foo=bar" to "query", + "https://api.example.com#metadata" to "fragment", + "http://api.example.com" to "https", + "https:///v1" to "host", + ) + + invalidResources.forEach { (resource, expectedMessage) -> + val failure = assertFailsWith { + testApplication { + application { + openIdConnect { + protectedResource(resource) + } + } + startApplication() + } + } + assertContains(failure.message.orEmpty(), "protectedResource(resource)") + assertContains(failure.message.orEmpty(), expectedMessage) + } + } + + private suspend fun ApplicationTestBuilder.configureProtectedResourceApplication( + resource: String, + protectedPath: String? = null, + ) { + application { + val oidc = openIdConnect { + protectedResource(resource) {} + } + val provider = oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + + if (protectedPath != null) { + routing { + authenticateWith(provider.bearer) { + get(protectedPath) { call.respondText("ok") } + } + } + } + } + } +}