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 7ff75392d01..08f116f0e77 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,6 +1,7 @@ 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 fun provider (Ljava/lang/String;Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function3;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 { @@ -9,6 +10,42 @@ public final class io/ktor/server/auth/oidc/Oidc$Companion : io/ktor/server/appl public synthetic fun install (Lio/ktor/util/pipeline/Pipeline;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; } +public final class io/ktor/server/auth/oidc/OidcAccessTokenConfig { + public final fun getAudiences ()Ljava/util/Set; + public final fun setAudiences (Ljava/util/Set;)V +} + +public final class io/ktor/server/auth/oidc/OidcAuthenticatedContextKt { + public static final fun getProvider (Lio/ktor/server/auth/oidc/OidcProviderContext;)Lio/ktor/server/auth/oidc/OidcProvider; +} + +public final class io/ktor/server/auth/oidc/OidcBearerConfig { + public final fun getTokenExtractor ()Lkotlin/jvm/functions/Function1; + public final fun setTokenExtractor (Lkotlin/jvm/functions/Function1;)V +} + +public final class io/ktor/server/auth/oidc/OidcBearerContext : io/ktor/server/auth/oidc/OidcProviderContext, io/ktor/server/auth/typesafe/AuthenticatedContext { + public fun principal (Lio/ktor/server/routing/RoutingContext;)Ljava/lang/Object; + public fun provider ()Lio/ktor/server/auth/oidc/OidcProvider; +} + +public final class io/ktor/server/auth/oidc/OidcJwtConfig { + public final fun disableJwkCache ()V + public final fun disableJwkRateLimit ()V + public final fun getAllowedAlgorithms ()Ljava/util/Set; + public final fun getClockSkew-UwyO8pc ()J + public final fun getJwkBuilder ()Lkotlin/jvm/functions/Function1; + public final fun getJwkProviderFactory ()Lkotlin/jvm/functions/Function1; + public final fun jwkCache-HG0u8IE (JJ)V + public static synthetic fun jwkCache-HG0u8IE$default (Lio/ktor/server/auth/oidc/OidcJwtConfig;JJILjava/lang/Object;)V + public final fun jwkRateLimit-HG0u8IE (JJ)V + public static synthetic fun jwkRateLimit-HG0u8IE$default (Lio/ktor/server/auth/oidc/OidcJwtConfig;JJILjava/lang/Object;)V + public final fun setAllowedAlgorithms (Ljava/util/Set;)V + public final fun setClockSkew-LRDsOJo (J)V + public final fun setJwkBuilder (Lkotlin/jvm/functions/Function1;)V + public final fun setJwkProviderFactory (Lkotlin/jvm/functions/Function1;)V +} + 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; @@ -39,16 +76,171 @@ public final class io/ktor/server/auth/oidc/OidcPluginConfig { } public final class io/ktor/server/auth/oidc/OidcProvider { + public final fun currentJwkProvider ()Lcom/auth0/jwk/JwkProvider; public final fun currentMetadata ()Lio/ktor/server/auth/oidc/OpenIdProviderMetadata; + public final fun getBearer ()Lio/ktor/server/auth/typesafe/DefaultAuthScheme; 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 accessToken (Lkotlin/jvm/functions/Function1;)V + public final fun bearer (Lkotlin/jvm/functions/Function1;)V + public static synthetic fun bearer$default (Lio/ktor/server/auth/oidc/OidcProviderConfig;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun getIssuer ()Ljava/lang/String; + public final fun getMetadata ()Lio/ktor/server/auth/oidc/OpenIdProviderMetadata; public final fun getName ()Ljava/lang/String; + public final fun jwt (Lkotlin/jvm/functions/Function1;)V public final fun setIssuer (Ljava/lang/String;)V + public final fun setMetadata (Lio/ktor/server/auth/oidc/OpenIdProviderMetadata;)V +} + +public abstract interface class io/ktor/server/auth/oidc/OidcProviderContext { + public abstract fun provider ()Lio/ktor/server/auth/oidc/OidcProvider; +} + +public abstract class io/ktor/server/auth/oidc/OidcToken { + public static final field Companion Lio/ktor/server/auth/oidc/OidcToken$Companion; + public synthetic fun (ILkotlinx/serialization/internal/SerializationConstructorMarker;)V + public static final synthetic fun write$Self (Lio/ktor/server/auth/oidc/OidcToken;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class io/ktor/server/auth/oidc/OidcToken$Access : io/ktor/server/auth/oidc/OidcToken { + public static final field Companion Lio/ktor/server/auth/oidc/OidcToken$Access$Companion; + public final fun getClaims ()Lio/ktor/server/auth/oidc/TokenClaims; + public final fun getClientId ()Ljava/lang/String; + public final fun getUserInfo ()Lio/ktor/server/auth/oidc/OidcToken$UserInfo; + public final fun getValue ()Ljava/lang/String; +} + +public final synthetic class io/ktor/server/auth/oidc/OidcToken$Access$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OidcToken$Access$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/OidcToken$Access; + 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/OidcToken$Access;)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/OidcToken$Access$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/ktor/server/auth/oidc/OidcToken$Companion { + public final fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/ktor/server/auth/oidc/OidcToken$Id : io/ktor/server/auth/oidc/OidcToken { + public static final field Companion Lio/ktor/server/auth/oidc/OidcToken$Id$Companion; + public final fun getAccessToken ()Ljava/lang/String; + public final fun getAccessTokenClaims ()Lio/ktor/server/auth/oidc/TokenClaims; + public final fun getClaims ()Lio/ktor/server/auth/oidc/TokenClaims; + public final fun getRefreshToken ()Ljava/lang/String; + public final fun getUserInfo ()Lio/ktor/server/auth/oidc/OidcToken$UserInfo; + public final fun getValue ()Ljava/lang/String; +} + +public final synthetic class io/ktor/server/auth/oidc/OidcToken$Id$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OidcToken$Id$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/OidcToken$Id; + 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/OidcToken$Id;)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/OidcToken$Id$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/ktor/server/auth/oidc/OidcToken$Opaque : io/ktor/server/auth/oidc/OidcToken { + public static final field Companion Lio/ktor/server/auth/oidc/OidcToken$Opaque$Companion; + public final fun getIntrospection ()Lio/ktor/server/auth/oidc/OpaqueTokenIntrospection; + public final fun getValue ()Ljava/lang/String; +} + +public final synthetic class io/ktor/server/auth/oidc/OidcToken$Opaque$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OidcToken$Opaque$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/OidcToken$Opaque; + 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/OidcToken$Opaque;)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/OidcToken$Opaque$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/ktor/server/auth/oidc/OidcToken$UserInfo { + public static final field Companion Lio/ktor/server/auth/oidc/OidcToken$UserInfo$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEmail ()Ljava/lang/String; + public final fun getEmailVerified ()Ljava/lang/Boolean; + public final fun getFamilyName ()Ljava/lang/String; + public final fun getGivenName ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getPicture ()Ljava/lang/String; + public final fun getPreferredUsername ()Ljava/lang/String; + public final fun getSubject ()Ljava/lang/String; +} + +public final synthetic class io/ktor/server/auth/oidc/OidcToken$UserInfo$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OidcToken$UserInfo$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/OidcToken$UserInfo; + 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/OidcToken$UserInfo;)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/OidcToken$UserInfo$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/ktor/server/auth/oidc/OpaqueTokenIntrospection { + public static final field Companion Lio/ktor/server/auth/oidc/OpaqueTokenIntrospection$Companion; + public fun (ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getActive ()Z + public final fun getAudience ()Ljava/util/List; + public final fun getClaims ()Lkotlinx/serialization/json/JsonObject; + public final fun getClientId ()Ljava/lang/String; + public final fun getExpiresAt ()Ljava/lang/Long; + public final fun getIssuedAt ()Ljava/lang/Long; + public final fun getIssuer ()Ljava/lang/String; + public final fun getJwtId ()Ljava/lang/String; + public final fun getNotBefore ()Ljava/lang/Long; + public final fun getScope ()Ljava/lang/String; + public final fun getSubject ()Ljava/lang/String; + public final fun getTokenType ()Ljava/lang/String; + public final fun getUsername ()Ljava/lang/String; +} + +public final synthetic class io/ktor/server/auth/oidc/OpaqueTokenIntrospection$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OpaqueTokenIntrospection$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/ktor/server/auth/oidc/OpaqueTokenIntrospection; + 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/OpaqueTokenIntrospection;)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/OpaqueTokenIntrospection$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; } public final class io/ktor/server/auth/oidc/OpenIdDiscoveryException : java/lang/RuntimeException { @@ -118,3 +310,21 @@ public final class io/ktor/server/auth/oidc/OpenIdProviderMetadataKt { public static final fun fetchOpenIdMetadata (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +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; + public final fun getAlgorithm ()Ljava/lang/String; + public final fun getAudience ()Ljava/util/List; + public final fun getExpiresAt ()Lkotlin/time/Instant; + public final fun getHeader ()Lkotlinx/serialization/json/JsonObject; + public final fun getIssuedAt ()Lkotlin/time/Instant; + public final fun getIssuer ()Ljava/lang/String; + public final fun getJwtId ()Ljava/lang/String; + public final fun getKeyId ()Ljava/lang/String; + public final fun getNotBefore ()Lkotlin/time/Instant; + public final fun getPayload ()Lkotlinx/serialization/json/JsonObject; + public final fun getSubject ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun headerString (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 09985a3b7c4..9fff2a9768b 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 @@ -8,19 +8,25 @@ plugins { } kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + } + sourceSets { jvmMain.dependencies { api(projects.ktorServerCore) + api(projects.ktorServerAuth) + api(projects.ktorServerAuthJwt) 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) 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 4e4f27cf9f0..703ebe3bf46 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 @@ -17,19 +17,103 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json +import kotlin.reflect.KClass 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. + * Installs per-provider Bearer token authentication (`bearer { }`) that validates JWT access tokens issued by the + * provider. Use [OidcProvider.bearer] with `authenticateWith`. * - * 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]. + * Provider metadata is fetched automatically from the issuer's discovery document + * (`/.well-known/openid-configuration`) and periodically refreshed unless a provider configures static + * [OpenIdProviderMetadata]. + * + * Initial discovery is part of provider registration. The suspend [provider] functions discover metadata, install + * provider routes, and start periodic refresh before returning the registered provider. Environment-based + * configuration is used only as 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]. + * + * ## Full configuration example + * The example below registers providers from a suspend application module because provider registration performs + * initial discovery. + * + * ```kotlin + * val oidc = openIdConnect { + * // Optional: use a shared HTTP client instead of the plugin's internal one. + * // httpClient = myHttpClient + * + * // How often to re-fetch the discovery document. Set to ZERO to disable. + * discoveryRefreshInterval = 15.minutes + * + * // Provider registration waits for initial discovery and fails after the final unsuccessful attempt. + * // Defaults to one attempt. + * initialDiscoveryAttempts = 3 + * initialDiscoveryRetryDelay = 1.minutes + * + * } + * + * val google = oidc.provider("google") { + * issuer = "https://accounts.google.com" + * + * // JWT settings used for access-token verification. + * jwt { + * clockSkew = 60.seconds + * } + * + * // Access-token policy. Configure resource audiences explicitly. + * accessToken { + * audiences = setOf("my-api") + * } + * + * // Bearer token authentication — protects API routes via Bearer tokens. + * bearer { + * // Optional: customise where the token is extracted from. + * tokenExtractor = { call -> call.request.cookies["MY_TOKEN"] } + * } + * } + * + * // Protect routes using typed provider capabilities. + * routing { + * authenticateWith(google.bearer) { + * get("/profile") { + * val user = principal as OidcToken.Access + * call.respond("Logged in as ${user.userInfo?.name}") + * } + * } + * + * authenticateWith(google.bearer) { + * get("/api/me") { + * val user = principal as OidcToken.Access + * call.respond(user.userInfo) + * } + * } + * } + * ``` + * + * ## Environment-based configuration + * + * Provider defaults can also be loaded from `application.conf` (or equivalent): + * ```hocon + * ktor.openid.google { + * issuer = "https://accounts.google.com" + * } + * ``` + * + * Environment-based entries are applied by declaring the same provider name on the installed [Oidc] + * instance: + * ```kotlin + * val oidc = openIdConnect { } + * val google = oidc.provider("google") { + * accessToken { + * audiences = setOf("api") + * } + * bearer() + * } + * ``` * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.Oidc) */ @@ -39,27 +123,20 @@ public class Oidc internal constructor( private val client: HttpClient, ) { @Volatile - private var providers: Map = linkedMapOf() + 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( + @PublishedApi + internal suspend fun

provider( name: String, - configure: OidcProviderConfig.() -> Unit, - ): OidcProvider { - val config = reserveProviderName(name, configure) + principalType: KClass

, + transformPrincipal: PrincipalTransformer

, + configure: OidcProviderConfig

.() -> Unit, + ): OidcProvider

{ + val config = reserveProviderName(name, principalType, transformPrincipal, configure) try { val provider = discoverProvider(config) commitProvider(provider) @@ -70,23 +147,64 @@ public class Oidc internal constructor( } } - private suspend fun reserveProviderName( + /** + * Configures an OpenID Connect provider for custom route principal type [P]. + * + * The [transformPrincipal] callback maps a verified [OidcToken] to [P]. Return `null` to reject + * the verified token for this provider. + * + * @param name provider name used in generated routes and authentication scheme names. Must contain lowercase + * letters, digits, and hyphen-separated segments only. + * @param transformPrincipal maps verified OpenID Connect principals to the typed route principal. + * @param configure configures discovery, token validation, and Bearer authentication. + * @return configured provider whose route-facing capabilities use principal type [P]. + * @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 inline fun provider( + name: String, + noinline transformPrincipal: PrincipalTransformer

, + noinline configure: OidcProviderConfig

.() -> Unit, + ): OidcProvider

= + provider(name, principalType = P::class, transformPrincipal, configure) + + /** + * Configures an OpenID Connect provider that exposes [OidcToken] directly to routes. + * + * @param name provider name used in generated routes and authentication scheme names. Must contain lowercase + * letters, digits, and hyphen-separated segments only. + * @param configure configures discovery, token validation, and Bearer authentication. + * @return configured provider whose route-facing capabilities use [OidcToken]. + * @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 - ): OidcProviderConfig = providerRegistrationMutex.withLock { + configure: OidcProviderConfig.() -> Unit, + ): OidcProvider = + provider(name, transformPrincipal = { it }, configure) + + private suspend fun

reserveProviderName( + name: String, + principalType: KClass

, + transformPrincipal: PrincipalTransformer

, + 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 } + val providerConfig = OidcProviderConfig(name, principalType).apply { + config.environmentProviders[name]?.let { env -> applyEnvDefaults(env) } } try { providerConfig.configure() providerConfig.validate() + providerConfig.principalTransformer = transformPrincipal val issuer = providerConfig.issuer require(providers.values.none { it.issuer == issuer } && pendingProviderIssuers.add(issuer)) { "Duplicate OIDC issuer found for provider $name: $issuer" @@ -98,16 +216,16 @@ public class Oidc internal constructor( } } - private suspend fun discoverProvider(config: OidcProviderConfig): OidcProvider { + private suspend fun

discoverProvider(config: OidcProviderConfig

): OidcProvider

{ val provider = OidcProvider(config.name, client, config) - val metadata = withContext(Dispatchers.IO) { + val metadata = config.metadata ?: withContext(Dispatchers.IO) { discoverInitialMetadata(provider) } provider.updateMetadata(metadata) return provider } - private suspend fun commitProvider(provider: OidcProvider) = providerRegistrationMutex.withLock { + private suspend fun commitProvider(provider: OidcProvider<*>) = providerRegistrationMutex.withLock { application.startRefreshingMetadata(provider) providers = providers + (provider.name to provider) config.environmentProviders.remove(provider.name) @@ -122,34 +240,32 @@ public class Oidc internal constructor( } } - private suspend fun discoverInitialMetadata(provider: OidcProvider): OpenIdProviderMetadata { - var lastFailure: Throwable? = null - repeat(config.initialDiscoveryAttempts) { attempt -> + private suspend fun discoverInitialMetadata(provider: OidcProvider<*>): OpenIdProviderMetadata { + val maxAttempts = config.initialDiscoveryAttempts + repeat(maxAttempts) { attempt -> try { - return client.discoverVerified(provider.issuer) + return client.fetchOpenIdMetadata(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) + if (nextAttempt >= maxAttempts) { + val message = "Failed to discover OpenID configuration after $maxAttempts attempt(s)" + throw OpenIdDiscoveryException(message, cause) } + provider.logger.warn( + "OpenID configuration discovery failed. Retrying attempt $nextAttempt/$maxAttempts: ${cause.message}" + ) + delay(config.initialDiscoveryRetryDelay) } } - throw OpenIdDiscoveryException( - "Failed to discover OpenID configuration after ${config.initialDiscoveryAttempts} attempt(s)", - lastFailure, - ) + error("Should not reach here") } - private fun Application.startRefreshingMetadata(provider: OidcProvider) { - if (!config.discoveryRefreshInterval.isPositive()) { + private fun Application.startRefreshingMetadata(provider: OidcProvider<*>) { + if (provider.config.metadata != null || !config.discoveryRefreshInterval.isPositive()) { return } launch(Dispatchers.IO) { @@ -164,7 +280,7 @@ public class Oidc internal constructor( } delay(duration) try { - val newMetadata = client.discoverVerified(provider.issuer) + val newMetadata = client.fetchOpenIdMetadata(provider.issuer) provider.updateMetadata(newMetadata) consecutiveFailures = 0 } catch (cause: CancellationException) { @@ -184,7 +300,7 @@ public class Oidc internal constructor( } public companion object : BaseApplicationPlugin { - override val key: AttributeKey = AttributeKey("OIDC") + override val key: AttributeKey = AttributeKey("Oidc") @OptIn(ExperimentalKtorApi::class) override fun install( @@ -219,6 +335,11 @@ public class Oidc internal constructor( } } +private fun

OidcProviderConfig

.applyEnvDefaults(env: OidcPluginConfig.EnvConfig) = + apply { + issuer = env.issuer + } + /** * Details of a failed periodic OpenID Connect discovery metadata refresh. * Routes and token validation continue with the last successful discovery document. @@ -228,7 +349,7 @@ public class Oidc internal constructor( * @property cause failure raised while fetching or validating discovery metadata. */ public class OidcMetadataRefreshFailure( - public val provider: OidcProvider, + public val provider: OidcProvider<*>, public val consecutiveFailures: Int, public val cause: Throwable ) { @@ -246,11 +367,26 @@ public val OidcMetadataRefreshFailed: EventDefinition { + /** + * Returns the OpenID Connect provider associated with the current authenticated route. + */ + public fun provider(): OidcProvider

+} + +/** + * OpenID Connect provider for the current authenticated route. + * + * The provider name is taken from the [OidcProviderContext] used by `authenticateWith`. + */ +@ExperimentalKtorApi +context(ctx: OidcProviderContext

) +public val

provider: OidcProvider

+ get() = ctx.provider() + +/** + * Route context used by OpenID Connect Bearer authentication. + * + * It exposes the typed authenticated principal and provider-bound helpers inside + * `authenticateWith(provider.bearer)` route bodies. + * + * @param P provider principal type exposed to the route. + */ +@KtorDsl +@OptIn(ExperimentalKtorApi::class) +public class OidcBearerContext

internal constructor( + default: DefaultAuthenticatedContext

, + private val provider: OidcProvider

, +) : AuthenticatedContext

by default, OidcProviderContext

{ + + override fun provider(): OidcProvider

= provider +} + +/** + * Typed Bearer authentication scheme for an OpenID Connect provider. + * + * @param P provider principal type exposed to the route. + */ +@OptIn(ExperimentalKtorApi::class) +public typealias OidcBearerScheme

= DefaultAuthScheme> 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 new file mode 100644 index 00000000000..b84f692b35c --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt @@ -0,0 +1,80 @@ +/* + * 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.http.* +import io.ktor.http.auth.* +import io.ktor.http.auth.AuthScheme +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.typesafe.* +import io.ktor.server.response.* +import io.ktor.utils.io.* +import org.slf4j.Logger + +private const val AuthorizationHeaderLogLimit: Int = 96 + +@OptIn(InternalAPI::class) +internal fun

OidcProvider

.createBearerScheme(): OidcBearerScheme

{ + val extractor = bearerConfig.tokenExtractor + return bearer( + name = "$name-bearer", + principalType = principalType, + contextFactory = { default -> OidcBearerContext(default, provider = this) }, + ) { + description = "OpenID Connect Bearer" + + authHeader { call -> call.extractBearerHeader(extractor, logger) } + + authenticate { credential -> + runCatching { + val principal = verifyAccessToken(credential.token) + transformPrincipal(principal) + }.onFailure { + logger.trace("OpenID access token authentication failed {}", it.message) + }.getOrNull() + } + + onUnauthorized = { _ -> + val challenge = HttpAuthHeader.Parameterized( + authScheme = AuthScheme.Bearer, + parameters = emptyMap(), + ) + call.respond(UnauthorizedResponse(challenge)) + } + } +} + +private fun ApplicationCall.extractBearerHeader(extractor: TokenExtractor?, logger: Logger): HttpAuthHeader? { + val token = if (extractor == null) { + val header = request.headers[HttpHeaders.Authorization] ?: return null + val parsed = runCatching { + parseAuthorizationHeader(headerValue = header) + }.onFailure { cause -> + logger.trace( + "Malformed OpenID Connect Authorization header ignored: '{}': {}", + header.truncateForLog(), + cause.message, + ) + }.getOrNull() + val bearer = parsed as? HttpAuthHeader.Single + bearer?.takeIf { it.authScheme == AuthScheme.Bearer }?.blob + } else { + extractor(this) + } + val blob = token?.takeIf { it.isNotBlank() } ?: return null + return HttpAuthHeader.Single(authScheme = AuthScheme.Bearer, blob = blob) +} + +private fun String.truncateForLog(): String { + val sanitized = replace('\r', ' ').replace('\n', ' ') + return if (sanitized.length <= AuthorizationHeaderLogLimit) { + sanitized + } else { + sanitized.take(AuthorizationHeaderLogLimit) + "..." + } +} 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 18e3634ed4c..bdc01b993d8 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 @@ -4,12 +4,18 @@ package io.ktor.server.auth.oidc +import com.auth0.jwk.JwkProvider +import com.auth0.jwk.JwkProviderBuilder import io.ktor.client.* +import io.ktor.http.auth.* import io.ktor.server.application.* import io.ktor.server.config.* +import io.ktor.server.routing.* import io.ktor.utils.io.* import kotlinx.serialization.Serializable +import kotlin.reflect.KClass import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -47,7 +53,7 @@ public class OidcPluginConfig { * * 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]. + * fails with [OpenIdDiscoveryException]. */ public var initialDiscoveryAttempts: Int = 1 @@ -90,22 +96,251 @@ public class OidcPluginConfig { /** * Configuration for a single OpenID Connect provider (issuer). * - * @property name provider name used by this application. + * The provider is the typed root for route-facing capabilities. Bearer schemes created from this configuration expose + * the same principal type [P]. + * + * @property name provider name used for generated authentication scheme names. * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcProviderConfig) */ @KtorDsl -public class OidcProviderConfig internal constructor( +public class OidcProviderConfig

internal constructor( public val name: String, + internal val principalType: KClass

, + internal var principalTransformer: PrincipalTransformer

? = null ) { /** - * Issuer URL. Used for OpenID Connect discovery (`/.well-known/openid-configuration`). + * Issuer URL. Used for OpenID Connect discovery (`/.well-known/openid-configuration`) unless + * [metadata] is configured. */ public lateinit var issuer: String + /** + * Static OpenID Provider metadata for this provider. + * + * When configured, the provider skips initial discovery and disables periodic metadata refresh for this + * provider. + */ + public var metadata: OpenIdProviderMetadata? = null + + internal val jwtConfig: OidcJwtConfig = OidcJwtConfig() + internal var accessTokenConfig: OidcAccessTokenConfig? = null + internal var bearerConfig: OidcBearerConfig? = null + + internal val accessTokenAllowed: Boolean + get() = accessTokenConfig != null + + /** + * Configures JWT verification for JWT access tokens. + */ + public fun jwt(configure: OidcJwtConfig.() -> Unit) { + jwtConfig.apply(configure) + } + + /** + * Configures access-token acceptance for Bearer authentication. + */ + public fun accessToken(configure: OidcAccessTokenConfig.() -> Unit) { + accessTokenConfig = (accessTokenConfig ?: OidcAccessTokenConfig()).apply(configure) + } + + /** + * Enables Bearer token authentication and configures token extraction for this provider. + * + * Bearer authentication accepts access tokens only when [accessToken] is also configured with at least one + * expected audience. + */ + public fun bearer(configure: OidcBearerConfig.() -> Unit = {}) { + bearerConfig = OidcBearerConfig().apply(configure) + } + internal fun validate() { require(::issuer.isInitialized && issuer.isNotBlank()) { "issuer must be configured" } + metadata?.validate(expectedIssuer = issuer) + require(bearerConfig == null || accessTokenConfig != null) { + "Bearer authentication requires accessToken { audiences = ... }" + } + jwtConfig.validate() + accessTokenConfig?.validate() + } +} + +/** + * Maps a verified raw OpenID Connect principal to the route principal type [P]. + * + * The transformer receives the current [RoutingContext] and verified principal. + * + * Return `null` to reject a verified JWT access token for this provider. + * + * @param P the principal type exposed to typed route handlers. + */ +public typealias PrincipalTransformer

= suspend RoutingContext.(OidcToken) -> P? + +/** + * JWT verification configuration. + * + * @property clockSkew accepted JWT clock skew. + * @property allowedAlgorithms accepted JWT signing algorithms, or `null` to use provider defaults. + * @property jwkProviderFactory custom JWK provider factory for JWT signature verification. + * @property jwkBuilder additional customization for the default JWK provider builder. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcJwtConfig) + */ +@KtorDsl +public class OidcJwtConfig internal constructor() { + internal class CacheConfig( + val size: Long, + val expiresIn: Duration, + ) { + init { + require(size > 0) { "cache maxEntries must be positive" } + require(expiresIn.isPositive()) { "cache duration must be positive" } + } + } + + internal class RateLimitConfig( + val bucketSize: Long, + val refillDuration: Duration, + ) { + init { + require(bucketSize > 0) { "bucketSize must be positive" } + require(refillDuration.isPositive()) { "rateLimit refillDuration must be positive" } + } + } + + /** + * Accepted JWT clock skew in seconds. + */ + public var clockSkew: Duration = 60.seconds + + /** + * Accepted JWT signing algorithms. + * + * When `null`, JWT access tokens keep the default RSA/EC verification behavior. `none` and HMAC algorithms are + * never accepted. + */ + public var allowedAlgorithms: Set? = null + + /** + * Customize JWK provider creation for JWT signature verification. + * + * A custom provider factory owns JWK fetching, caching, and rate limiting. It cannot be combined with + * [jwkCache], [disableJwkCache], [jwkRateLimit], or [disableJwkRateLimit]. + */ + public var jwkProviderFactory: ((String) -> JwkProvider)? = null + + /** + * Additional JWK provider builder customization for JWT signature verification. + * + * This low-level hook is applied after [jwkCache], [disableJwkCache], [jwkRateLimit], and [disableJwkRateLimit], + * so it can still override the final [JwkProviderBuilder] behavior. + */ + public var jwkBuilder: JwkProviderBuilder.() -> Unit = {} + + internal var jwkCacheEnabled: Boolean = true + internal var jwkCacheConfig: CacheConfig? = null + internal var jwkCacheConfigured: Boolean = false + + internal var jwkRateLimitEnabled: Boolean = true + internal var jwkRateLimitConfig: RateLimitConfig? = null + internal var jwkRateLimitConfigured: Boolean = false + + /** + * Configures caching for fetched JSON Web Keys. + * + * @param maxEntries maximum number of keys to cache, defaults to 5. + * @param duration how long cached keys remain valid before being refreshed, defaults to 10 hours. + */ + public fun jwkCache(maxEntries: Long = 5, duration: Duration = 10.hours) { + jwkCacheEnabled = true + jwkCacheConfig = CacheConfig(maxEntries, duration) + jwkCacheConfigured = true + } + + /** + * Disables caching of JSON Web Keys. + */ + public fun disableJwkCache() { + jwkCacheEnabled = false + jwkCacheConfigured = true + } + + /** + * Configures rate limiting for JWKS endpoint requests. + * + * @param bucketSize maximum number of requests allowed in the time window, defaults to 10. + * @param refillDuration time window for the rate limit bucket, defaults to 1 minute. + */ + public fun jwkRateLimit(bucketSize: Long = 10, refillDuration: Duration = 1.minutes) { + jwkRateLimitEnabled = true + jwkRateLimitConfig = RateLimitConfig(bucketSize, refillDuration) + jwkRateLimitConfigured = true + } + + /** + * Disables rate limiting for JWKS endpoint requests. + */ + public fun disableJwkRateLimit() { + jwkRateLimitEnabled = false + jwkRateLimitConfigured = true + } + + internal fun validate() { + require(jwkProviderFactory == null || (!jwkCacheConfigured && !jwkRateLimitConfigured)) { + "jwt { jwkProviderFactory = ... } cannot be combined with jwkCache or jwkRateLimit configuration" + } + allowedAlgorithms?.forEach { algorithm -> + require(algorithm.jwaName != null) { + "jwt { allowedAlgorithms = ... } supports only RSA and EC JWA signature algorithms" + } + } } } + +/** + * Access-token verification policy. + * + * Access-token authentication is disabled unless this block is configured. Configure resource audiences explicitly. + * + * @property audiences accepted resource identifiers for this server. Access tokens must include at least one value + * from this set. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcAccessTokenConfig) + */ +@KtorDsl +public class OidcAccessTokenConfig internal constructor() { + /** + * Expected resource identifiers. Access tokens must include at least one of these audiences. + */ + public var audiences: Set = emptySet() + + internal fun validate() { + require(audiences.isNotEmpty()) { + "accessToken { audiences = ... } must be configured" + } + } +} + +/** + * Extracts a Bearer token candidate from an application call. + * + * Return `null` when this source does not contain a token. + */ +public typealias TokenExtractor = (ApplicationCall) -> String? + +/** + * Bearer token extraction configuration for a discovered issuer. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcBearerConfig) + */ +@KtorDsl +public class OidcBearerConfig internal constructor() { + /** + * Custom token extractor for Bearer authentication. + * + * When `null`, the provider reads the standard `Authorization: Bearer ` header. + */ + public var tokenExtractor: TokenExtractor? = null +} 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 deleted file mode 100644 index 71395d32d36..00000000000 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcDiscovery.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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 index e5aef9ec15b..c9dc28e8031 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 @@ -2,36 +2,66 @@ * 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 com.auth0.jwk.JwkProvider +import com.auth0.jwk.JwkProviderBuilder import io.ktor.client.* +import io.ktor.server.auth.typesafe.* +import io.ktor.server.routing.* +import io.ktor.utils.io.* import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.net.URI +import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass +import kotlin.time.toJavaDuration /** - * Configured OpenID Connect provider. + * Typed authentication capabilities for one configured OpenID Connect provider. + * + * [bearer] is available when the provider was configured with `bearer { }`. * - * @property name provider name. + * @param P principal type exposed by this provider's route-facing capabilities. + * @property name provider name. It is also used to derive the Bearer scheme name (`{name}-bearer`). * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcProvider) */ -public class OidcProvider internal constructor( +public class OidcProvider

internal constructor( public val name: String, internal val client: HttpClient, - internal val config: OidcProviderConfig, + internal val config: OidcProviderConfig

, ) { - /** - * Issuer URL configured for this provider. - */ public val issuer: String = config.issuer + internal val principalType: KClass

= config.principalType + + internal val jwtConfig: OidcJwtConfig + get() = checkNotNull(config.jwtConfig) { "JWT is not enabled for provider $name" } + + internal val accessTokenConfig: OidcAccessTokenConfig + get() = checkNotNull(config.accessTokenConfig) { "Access token is not enabled for provider $name" } + + internal val bearerConfig: OidcBearerConfig + get() = checkNotNull(config.bearerConfig) { + "Bearer scheme is not enabled. Call bearer { } in the provider $name." + } + 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) + val currentState = providerState + val nextJwkProvider = if (currentState?.metadata?.jwksUri == newMetadata.jwksUri) { + currentState.jwkProvider + } else { + computeJwkProvider(newMetadata.jwksUri) + } + providerState = OidcProviderState(newMetadata, nextJwkProvider) } /** @@ -44,8 +74,68 @@ public class OidcProvider internal constructor( checkNotNull(providerState) { "OpenID Connect metadata is not initialized for provider $name" }.metadata + + /** + * Returns the currently active JWK provider for this provider. + * The returned value can change after a successful periodic discovery refresh when the discovery document points + * to a different JWKS URI. + * + * @throws IllegalStateException when metadata has not been initialized yet. + */ + public fun currentJwkProvider(): JwkProvider = + checkNotNull(providerState) { + "JWK provider is not initialized for OpenID Connect provider $name" + }.jwkProvider + + context(ctx: RoutingContext) + internal suspend fun transformPrincipal(principal: OidcToken): P? { + config.principalTransformer?.let { transform -> + return ctx.transform(principal) + } + check(principalType.isInstance(principal)) { + "Invalid principal type. Returned principal is an instance of ${principal::class}" + } + @Suppress("UNCHECKED_CAST") + return principal as P + } + + private fun computeJwkProvider(jwksUri: String): JwkProvider { + val factory = jwtConfig.jwkProviderFactory + if (factory != null) { + return factory(jwksUri) + } + val jwksUrl = URI(jwksUri).toURL() + val builder = JwkProviderBuilder(jwksUrl) + when (jwtConfig.jwkCacheEnabled) { + false -> builder.cached(false) + else -> jwtConfig.jwkCacheConfig?.let { + builder.cached(it.size, it.expiresIn.toJavaDuration()) + } + } + when (jwtConfig.jwkRateLimitEnabled) { + false -> builder.rateLimited(false) + else -> jwtConfig.jwkRateLimitConfig?.let { + builder.rateLimited( + it.bucketSize, + it.refillDuration.inWholeMilliseconds, + TimeUnit.MILLISECONDS + ) + } + } + return builder.apply(jwtConfig.jwkBuilder).build() + } + + /** + * Typed Bearer authentication scheme. + * + * Use with `authenticateWith(provider.bearer)`. + * + * @throws IllegalStateException when the provider was not configured with `bearer { }`. + */ + public val bearer: OidcBearerScheme

by lazy { createBearerScheme() } } private data class OidcProviderState( val metadata: OpenIdProviderMetadata, + val jwkProvider: JwkProvider, ) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcToken.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcToken.kt new file mode 100644 index 00000000000..878dd7007af --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcToken.kt @@ -0,0 +1,152 @@ +/* + * 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 com.auth0.jwt.JWT +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +/** + * Token material returned or validated by the OpenID Connect plugin. + * + * This is the base type for all token-based principals in the OpenID Connect plugin. Use one of the concrete + * subclasses depending on the token source: + * - [Id] for full OpenID Connect flows with an ID token. + * - [Access] for JWT access tokens, for example, Bearer tokens. + * - [Opaque] for opaque access tokens handled via RFC 7662 introspection. + * + * The plugin creates token instances after validation. Constructors for token-bearing subclasses are internal, + * so applications cannot accidentally fabricate a verified token principal. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcToken) + */ +@Serializable +public sealed class OidcToken { + /** + * Token principal from an OpenID Connect flow containing an ID token. + * + * @property value verified ID token value. + * @property accessToken access token returned with the ID token. + * @property refreshToken refresh token returned by the token endpoint, or `null` when unavailable. + * @property userInfo normalized user claims extracted from the ID token or UserInfo endpoint. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcToken.Id) + */ + @Serializable + @SerialName("id_token") + public class Id internal constructor( + public val value: String, + public val accessToken: String, + public val refreshToken: String? = null, + public val userInfo: UserInfo, + ) : OidcToken() { + /** + * Decoded claims from [value]. Accessing these values does not perform verification by itself. + */ + public val claims: TokenClaims by lazy { TokenClaims(JWT.decode(value)) } + + /** + * Decoded claims from [accessToken]. Accessing this property requires the access token to be a JWT. + */ + public val accessTokenClaims: TokenClaims by lazy { TokenClaims(JWT.decode(accessToken)) } + } + + /** + * Token principal from a verified JWT access token. + * + * @property value verified JWT access token value. + * @property userInfo normalized user claims extracted from the token, or `null` when unavailable. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcToken.Access) + */ + @Serializable + @SerialName("access_token") + public class Access internal constructor( + public val value: String, + public val userInfo: UserInfo? = null, + ) : OidcToken() { + /** + * Decoded claims from [value]. Accessing these values does not perform verification by itself. + */ + public val claims: TokenClaims by lazy { TokenClaims(JWT.decode(value)) } + + /** + * Authorized party or client identifier from the JWT access token. + * + * The plugin checks the standard OpenID Connect `azp` claim first, then falls back to the OAuth `client_id` + * claim used by some providers. Returns `null` when neither claim is present. + */ + public val clientId: String? get() = claims.claimString("azp") ?: claims.claimString("client_id") + } + + /** + * Token principal from an opaque access token validated via RFC 7662 introspection. + * + * Opaque tokens cannot be decoded locally. They are accepted only when the provider configures + * [OpaqueTokenStrategy.Introspect]. + * + * @property value opaque access token value. + * @property introspection normalized introspection response returned by the authorization server. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcToken.Opaque) + */ + @Serializable + @SerialName("opaque_token") + public class Opaque internal constructor( + public val value: String, + public val introspection: OpaqueTokenIntrospection, + ) : OidcToken() + + /** + * Standard user claims extracted from an ID token payload, JWT access token payload, or UserInfo response. + * + * @property subject subject identifier. Must not be blank. + * @property name display name. + * @property email email address. + * @property emailVerified whether the provider has verified the email address. + * @property picture profile picture URL. + * @property givenName given name. + * @property familyName family name. + * @property preferredUsername preferred username. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcToken.UserInfo) + */ + @Serializable + public class UserInfo( + @SerialName("sub") public val subject: String, + public val name: String? = null, + public val email: String? = null, + @SerialName("email_verified") public val emailVerified: Boolean? = null, + public val picture: String? = null, + @SerialName("given_name") public val givenName: String? = null, + @SerialName("family_name") public val familyName: String? = null, + @SerialName("preferred_username") public val preferredUsername: String? = null, + ) { + init { + require(subject.isNotBlank()) { "subject must not be blank" } + } + } + + public companion object { + /** + * [SerializersModule] for polymorphic serialization of [OidcToken] subclasses. + * + * Register this module in your [kotlinx.serialization.json.Json] configuration when using custom token + * serialization. + */ + public val serializersModule: SerializersModule by lazy { + SerializersModule { + polymorphic(OidcToken::class) { + subclass(Id::class) + subclass(Access::class) + subclass(Opaque::class) + } + } + } + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenModels.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenModels.kt new file mode 100644 index 00000000000..3b7eea302b8 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenModels.kt @@ -0,0 +1,180 @@ +@file:OptIn(ExperimentalTime::class) + +package io.ktor.server.auth.oidc + +import com.auth0.jwt.interfaces.DecodedJWT +import com.auth0.jwt.interfaces.Payload +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import kotlin.io.encoding.Base64 +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlin.time.toKotlinInstant + +/** + * Structured JWT claims access. + * + * Claims are decoded from an already verified token by the OpenID Connect plugin. Accessing these values does not + * perform verification by itself. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.TokenClaims) + */ +public class TokenClaims internal constructor(private val jwt: DecodedJWT) { + /** + * Decoded JWT header as JSON. + */ + public val header: JsonObject get() = parseJsonObject(jwt.header) + + /** + * Decoded JWT payload claims as JSON. + */ + public val payload: JsonObject get() = parseJsonObject(jwt.payload) + + /** + * Key identifier from the JWT header. + */ + public val keyId: String? get() = jwt.keyId + + /** + * Type from the JWT header. + */ + public val type: String? get() = jwt.type + + /** + * Signing algorithm from the JWT header. + */ + public val algorithm: String? get() = jwt.algorithm + + /** + * Issuer claim. + */ + public val issuer: String? get() = jwt.issuer + + /** + * Subject claim. + */ + public val subject: String? get() = jwt.subject + + /** + * Audience claim values. + */ + public val audience: List get() = jwt.audience ?: emptyList() + + /** + * Expiration time. + */ + public val expiresAt: Instant? get() = jwt.expiresAtAsInstant?.toKotlinInstant() + + /** + * Not-before time. + */ + public val notBefore: Instant? get() = jwt.notBeforeAsInstant?.toKotlinInstant() + + /** + * Issuance time. + */ + public val issuedAt: Instant? get() = jwt.issuedAtAsInstant?.toKotlinInstant() + + /** + * JWT ID claim. + */ + public val jwtId: String? get() = jwt.id + + /** + * Returns a decoded JWT payload claim by name. + * + * @param name claim name. + * @return JSON claim value, or `null` when absent. + */ + public fun claim(name: String): JsonElement? = payload[name] + + /** + * Returns a decoded JWT payload claim as a string. + * + * @param name claim name. + * @return claim string value, or `null` when absent or not a JSON string. + */ + public fun claimString(name: String): String? = + claim(name)?.jsonPrimitive?.takeIf { it.isString }?.contentOrNull + + /** + * Returns a JWT header value as a string. + * + * @param name header name. + * @return header string value, or `null` when absent or not a JSON string. + */ + public fun headerString(name: String): String? = + header[name]?.jsonPrimitive?.takeIf { it.isString }?.contentOrNull + + private fun parseJsonObject(raw: String): JsonObject { + return runCatching { + val decoded = Base64.withPadding(Base64.PaddingOption.ABSENT).decode(raw) + Json.parseToJsonElement(decoded.decodeToString()) as? JsonObject + }.getOrNull() ?: JsonObject(emptyMap()) + } +} + +/** + * Normalized RFC 7662 token introspection response. + * + * @property active whether the token is currently active. + * @property scope OAuth scope string returned by the introspection endpoint. + * @property clientId client identifier associated with the token. + * @property username resource owner username, when returned by the authorization server. + * @property tokenType token type, such as `Bearer`. + * @property expiresAt expiration time as seconds since the Unix epoch. + * @property issuedAt issuance time as seconds since the Unix epoch. + * @property notBefore not-before time as seconds since the Unix epoch. + * @property subject subject identifier associated with the token. + * @property audience normalized token audiences. String audiences are preserved as a single value. + * @property issuer token issuer. + * @property jwtId token identifier. + * @property claims raw JSON claims returned by the introspection endpoint. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OpaqueTokenIntrospection) + */ +@Serializable +public class OpaqueTokenIntrospection( + public val active: Boolean, + public val scope: String? = null, + @SerialName("client_id") + public val clientId: String? = null, + public val username: String? = null, + @SerialName("token_type") + public val tokenType: String? = null, + @SerialName("exp") + public val expiresAt: Long? = null, + @SerialName("iat") + public val issuedAt: Long? = null, + @SerialName("nbf") + public val notBefore: Long? = null, + @SerialName("sub") + public val subject: String? = null, + @SerialName("aud") + public val audience: List = emptyList(), + @SerialName("iss") + public val issuer: String? = null, + @SerialName("jti") + public val jwtId: String? = null, + public val claims: JsonObject = JsonObject(emptyMap()), +) + +internal fun Payload.extractUserInfo(): OidcToken.UserInfo { + require(!subject.isNullOrBlank()) { + "subject claim is missing from the JWT payload" + } + return OidcToken.UserInfo( + subject = subject, + name = getClaim("name").asString(), + email = getClaim("email").asString(), + emailVerified = getClaim("email_verified").asBoolean(), + picture = getClaim("picture").asString(), + givenName = getClaim("given_name").asString(), + familyName = getClaim("family_name").asString(), + preferredUsername = getClaim("preferred_username").asString(), + ) +} + +internal fun Payload.extractUserInfoOrNull(): OidcToken.UserInfo? = + if (subject.isNullOrBlank()) null else extractUserInfo() diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt new file mode 100644 index 00000000000..525b3734344 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt @@ -0,0 +1,198 @@ +/* + * 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 com.auth0.jwk.Jwk +import com.auth0.jwk.JwkException +import com.auth0.jwk.JwkProvider +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTVerificationException +import com.auth0.jwt.interfaces.DecodedJWT +import io.ktor.http.auth.* +import io.ktor.utils.io.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.PublicKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +internal class OidcTokenRejectedException(message: String?) : RuntimeException(message) + +private fun rejectToken(message: String?): Nothing = + throw OidcTokenRejectedException(message) + +@OptIn(ExperimentalContracts::class) +private inline fun requireToken(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + } + if (condition) return + rejectToken(lazyMessage()) +} + +private enum class JwtTokenType { + IdToken, + AccessToken, + UserInfo, +} + +private val hmacAlgorithms = setOf("HS256", "HS384", "HS512") + +internal suspend fun OidcProvider<*>.verifyAccessToken(token: String): OidcToken { + try { + val decodeResult = runCatching { JWT.decode(token) } + return when { + decodeResult.isSuccess -> verifyJwtAccessToken(token, jwt = decodeResult.getOrThrow()) + else -> rejectToken(decodeResult.exceptionOrNull()?.message) + } + } catch (cause: CancellationException) { + throw cause + } catch (cause: OidcTokenRejectedException) { + throw cause + } catch (cause: Exception) { + rejectToken(cause.message) + } +} + +private suspend fun OidcProvider<*>.verifyJwtAccessToken( + token: String, + jwt: DecodedJWT +): OidcToken.Access { + val verifiedJwt = verifyJwtToken( + token = token, + jwt = jwt, + audiences = accessTokenConfig.audiences, + tokenType = JwtTokenType.AccessToken, + metadata = currentMetadata(), + jwkProvider = currentJwkProvider(), + ) + val userInfo = verifiedJwt.takeIf { it.subject != null }?.extractUserInfo() + return OidcToken.Access(token, userInfo) +} + +private suspend fun OidcProvider<*>.verifyJwtToken( + token: String, + jwt: DecodedJWT, + audiences: Collection, + tokenType: JwtTokenType, + metadata: OpenIdProviderMetadata, + jwkProvider: JwkProvider, +): DecodedJWT { + val tokenAlgorithm = requireAllowedAlgorithm(jwt, tokenType, metadata) + val keyId = jwt.keyId + val jwk = try { + withContext(Dispatchers.IO) { jwkProvider.get(keyId) } + } catch (cause: JwkException) { + rejectToken("JWT kid $keyId does not match any JWK: ${cause.message}") + } + requireToken(jwk.isUsableForJwsVerification(tokenAlgorithm)) { + "JWK $keyId cannot verify JWT algorithm ${tokenAlgorithm.jwaName}" + } + return verifyJwt(token, jwk, tokenAlgorithm, audiences, metadata) +} + +private fun OidcProvider<*>.verifyJwt( + token: String, + jwk: Jwk, + tokenAlgorithm: SignatureAlgorithm, + audiences: Collection, + metadata: OpenIdProviderMetadata, +): DecodedJWT = + try { + JWT + .require(tokenAlgorithm.toJwtAlgorithm(jwk.publicKey)) + .withIssuer(metadata.issuer) + .withAnyOfAudience(*audiences.toTypedArray()) + .acceptLeeway(jwtConfig.clockSkew.inWholeSeconds) + .build() + .verify(token) + } catch (cause: JWTVerificationException) { + rejectToken(cause.message) + } + +private fun OidcProvider<*>.requireAllowedAlgorithm( + jwt: DecodedJWT, + tokenType: JwtTokenType, + metadata: OpenIdProviderMetadata, +): SignatureAlgorithm { + val algorithmName = jwt.algorithm ?: rejectToken("JWT algorithm is missing") + requireToken(algorithmName != "none" && algorithmName !in hmacAlgorithms) { + "JWT algorithm $algorithmName is not accepted" + } + val algorithm = SignatureAlgorithm.fromJwaName(algorithmName) + ?: rejectToken("JWT algorithm $algorithmName is not accepted") + + val allowedAlgorithms = jwtConfig.allowedAlgorithms + ?.map { algorithm -> checkNotNull(algorithm.jwaName) } + ?: when (tokenType) { + JwtTokenType.IdToken -> metadata.idTokenSigningAlgValuesSupported + JwtTokenType.UserInfo -> metadata.userinfoSigningAlgValuesSupported + JwtTokenType.AccessToken -> null + } + requireToken(allowedAlgorithms == null || algorithmName in allowedAlgorithms) { + "JWT algorithm $algorithmName is not in the allowed algorithms: ${allowedAlgorithms!!.joinToString()}" + } + return algorithm +} + +private fun Jwk.isUsableForJwsVerification(tokenAlgorithm: SignatureAlgorithm): Boolean { + if (usage != null && usage != "sig") { + return false + } + if (operationsAsList != null && "verify" !in operationsAsList) { + return false + } + if (algorithm != null && algorithm != tokenAlgorithm.jwaName) { + return false + } + val jwkType = tokenAlgorithm.keyAlgorithm.jwkType ?: return false + return type == jwkType && curveSupportsAlgorithm(tokenAlgorithm) +} + +private val KeyAlgorithm.jwkType: String? + get() = when (this) { + KeyAlgorithm.RSA -> "RSA" + KeyAlgorithm.EC -> "EC" + else -> null + } + +private val SignatureAlgorithm.ecJwaCurve: String? + get() = when (this) { + SignatureAlgorithm.ECDSA_SHA_256 -> "P-256" + SignatureAlgorithm.ECDSA_SHA_384 -> "P-384" + SignatureAlgorithm.ECDSA_SHA_512 -> "P-521" + else -> null + } + +private fun Jwk.curveSupportsAlgorithm(algorithm: SignatureAlgorithm): Boolean { + val expectedCurve = algorithm.ecJwaCurve ?: return true + val curve = additionalAttributes["crv"] as? String ?: return true + return curve == expectedCurve +} + +private fun SignatureAlgorithm.toJwtAlgorithm(publicKey: PublicKey): Algorithm = + when (val publicKey = publicKey) { + is RSAPublicKey -> when (this) { + SignatureAlgorithm.RSA_SHA_256 -> Algorithm.RSA256(publicKey, null) + SignatureAlgorithm.RSA_SHA_384 -> Algorithm.RSA384(publicKey, null) + SignatureAlgorithm.RSA_SHA_512 -> Algorithm.RSA512(publicKey, null) + else -> error("Unsupported RSA JWT signing algorithm: $name") + } + + is ECPublicKey -> when (this) { + SignatureAlgorithm.ECDSA_SHA_256 -> Algorithm.ECDSA256(publicKey, null) + SignatureAlgorithm.ECDSA_SHA_384 -> Algorithm.ECDSA384(publicKey, null) + SignatureAlgorithm.ECDSA_SHA_512 -> Algorithm.ECDSA512(publicKey, null) + else -> error("Unsupported EC JWT signing algorithm: $name") + } + + else -> error("Unsupported JWK key type: ${publicKey::class.simpleName}") + } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OpenIdProviderMetadata.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OpenIdProviderMetadata.kt index 686f742938d..29a10193398 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OpenIdProviderMetadata.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OpenIdProviderMetadata.kt @@ -192,6 +192,15 @@ public class OpenIdProviderMetadata( public val checkSessionIframe: String? = null, ) +internal fun OpenIdProviderMetadata.validate(expectedIssuer: String) { + require(issuer == expectedIssuer) { + "OpenID issuer mismatch: expected exactly $expectedIssuer, got $issuer" + } + require(jwksUri.isNotBlank()) { "jwks_uri is missing" } + require(tokenEndpoint.isNotBlank()) { "token_endpoint is missing" } + require(authorizationEndpoint.isNotBlank()) { "authorization_endpoint is missing" } +} + /** * Fetches OpenID Connect discovery document from the authorization server. * @@ -208,6 +217,7 @@ public class OpenIdProviderMetadata( * @param issuer The issuer URL (e.g., "https://accounts.google.com") * @return The OpenID Connect configuration containing endpoints and metadata * @throws OpenIdDiscoveryException if the request fails or the response is invalid + * @throws IllegalArgumentException if the decoded metadata fails issuer or required endpoint validation * */ public suspend fun HttpClient.fetchOpenIdMetadata(issuer: String): OpenIdProviderMetadata { @@ -222,15 +232,7 @@ public suspend fun HttpClient.fetchOpenIdMetadata(issuer: String): OpenIdProvide } catch (e: Exception) { throw OpenIdDiscoveryException("Failed to fetch OpenID configuration from $issuer", e) } - if (config.jwksUri.isBlank()) { - throw OpenIdDiscoveryException("OpenID configuration from $issuer is missing jwks_uri") - } - if (config.authorizationEndpoint.isBlank()) { - throw OpenIdDiscoveryException("OpenID configuration from $issuer is missing authorization_endpoint") - } - if (config.tokenEndpoint.isBlank()) { - throw OpenIdDiscoveryException("OpenID configuration from $issuer is missing token_endpoint") - } + config.validate(expectedIssuer = issuer) return config } 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 66b4533800f..7eb81014560 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 @@ -187,10 +187,10 @@ class FetchOpenIdProviderMetadataTest { ) ) - val exception = assertFailsWith { + val exception = assertFailsWith { discoveryClient().fetchOpenIdMetadata(issuer) } - assertTrue(exception.message!!.contains("missing jwks_uri")) + assertTrue(exception.message!!.contains("jwks_uri is missing")) } @Test @@ -206,10 +206,10 @@ class FetchOpenIdProviderMetadataTest { ) ) - val exception = assertFailsWith { + val exception = assertFailsWith { discoveryClient().fetchOpenIdMetadata(issuer) } - assertTrue(exception.message!!.contains("missing authorization_endpoint")) + assertTrue(exception.message!!.contains("authorization_endpoint is missing")) } @Test @@ -223,10 +223,10 @@ class FetchOpenIdProviderMetadataTest { jwksUri = "$issuer/.well-known/jwks.json" ) ) - val exception = assertFailsWith { + val exception = assertFailsWith { discoveryClient().fetchOpenIdMetadata(issuer) } - assertTrue(exception.message!!.contains("missing token_endpoint")) + assertTrue(exception.message!!.contains("token_endpoint is missing")) } @Test diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcBearerJwtTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcBearerJwtTest.kt new file mode 100644 index 00000000000..bd0c7de487e --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcBearerJwtTest.kt @@ -0,0 +1,346 @@ +/* + * 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, ExperimentalTime::class) + +package io.ktor.server.auth.oidc + +import com.auth0.jwt.JWT +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.auth.* +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 kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.json.Json +import java.util.* +import kotlin.test.* +import kotlin.time.Clock +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +class OidcBearerJwtTest { + + @Test + fun `bearer authentication rejects invalid JWT inputs`() = testApplication { + val keys = OpenIdTestKeys() + val otherKeys = OpenIdTestKeys() + + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val oidcProvider = oidc.provider("google") { + issuer = ISSUER_URL + jwt { + jwkProviderFactory = { keys.jwkProvider } + } + accessToken { + audiences = setOf("api") + } + bearer() + } + + routing { + authenticateWith(oidcProvider.bearer) { + get("/protected") { + val accessToken = principal as OidcToken.Access + call.respondText(accessToken.userInfo?.subject ?: "missing") + } + } + } + } + + val validToken = keys.token(audience = "api", subject = "valid-user") + val valid = client.get("/protected") { + header(HttpHeaders.Authorization, "Bearer $validToken") + } + assertEquals(HttpStatusCode.OK, valid.status) + assertEquals("valid-user", valid.bodyAsText()) + + val expired = keys.token( + audience = "api", + subject = "expired-user", + expiresAt = Date(Clock.System.now().minus(60.seconds).toEpochMilliseconds()), + ) + val failures = listOf( + null, + "Basic $validToken", + "Bearer not-a-jwt", + "Bearer ${keys.hmacToken(audience = "api", subject = "hmac")}", + "Bearer ${keys.unsignedToken(audience = "api", subject = "unsigned")}", + "Bearer ${keys.token(audience = "api", subject = "wrong-issuer", issuer = "https://issuer.example.net")}", + "Bearer ${otherKeys.token(audience = "api", subject = "wrong-signature", keyId = "kid-1")}", + "Bearer ${keys.token(audience = "other-api", subject = "wrong-audience")}", + "Bearer $expired", + ) + + for (authorizationHeader in failures) { + val response = client.get("/protected") { + authorizationHeader?.let { header(HttpHeaders.Authorization, it) } + } + assertEquals(HttpStatusCode.Unauthorized, response.status, "header=$authorizationHeader") + } + } + + @Test + fun `bearer authentication accepts JWT access token without subject`() = testApplication { + val keys = OpenIdTestKeys() + + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val oidcProvider = oidc.provider("auth0") { + issuer = ISSUER_URL + jwt { + jwkProviderFactory = { keys.jwkProvider } + } + accessToken { + audiences = setOf("api") + } + bearer() + } + + routing { + authenticateWith(oidcProvider.bearer) { + get("/protected") { + val accessToken = principal as OidcToken.Access + call.respondText( + "${accessToken.userInfo?.subject ?: "missing"}:${accessToken.clientId ?: "missing"}" + ) + } + } + } + } + + val response = client.get("/protected") { + header( + HttpHeaders.Authorization, + "Bearer ${keys.token(audience = "api", subject = null, clientId = "service-client")}" + ) + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("missing:service-client", response.bodyAsText()) + } + + @Test + fun `token claims headerString preserves embedded quotes`() { + val keys = OpenIdTestKeys() + val token = keys.token(audience = "api", headerClaims = mapOf("quoted" to "a\"b")) + val claims = TokenClaims(JWT.decode(token)) + + assertEquals("a\"b", claims.headerString("quoted")) + } + + @Test + fun `principal serialization uses stable token serial names`() { + val json = Json { + serializersModule = OidcToken.serializersModule + } + val serializer = PolymorphicSerializer(OidcToken::class) + + val accessToken = json.encodeToString(serializer, OidcToken.Access("access-token")) + val idToken = json.encodeToString( + serializer, + OidcToken.Id( + value = "id-token", + accessToken = "access-token", + userInfo = OidcToken.UserInfo(subject = "user"), + ) + ) + + assertContains(accessToken, "\"type\":\"access_token\"") + assertContains(idToken, "\"type\":\"id_token\"") + } + + @Test + fun `custom token source replaces authorization header`() = testApplication { + val keys = OpenIdTestKeys() + + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val oidcProvider = oidc.provider("auth0") { + issuer = ISSUER_URL + jwt { + jwkProviderFactory = { keys.jwkProvider } + } + accessToken { + audiences = setOf("custom-api") + } + bearer { + tokenExtractor = { call -> + call.request.headers["X-Api-Token"] + } + } + } + + routing { + authenticateWith(oidcProvider.bearer) { + get("/custom") { + val accessToken = principal as OidcToken.Access + call.respondText(accessToken.userInfo?.subject ?: "missing") + } + } + } + } + + val token = keys.token(audience = "custom-api", subject = "custom-user") + val authorizationHeaderIgnored = client.get("/custom") { + header(HttpHeaders.Authorization, "Bearer $token") + } + assertEquals(HttpStatusCode.Unauthorized, authorizationHeaderIgnored.status) + + val customHeaderAccepted = client.get("/custom") { + header("X-Api-Token", token) + } + assertEquals(HttpStatusCode.OK, customHeaderAccepted.status) + assertEquals("custom-user", customHeaderAccepted.bodyAsText()) + } + + @Test + fun `malformed authorization header is logged at trace level with truncated value`() = testApplication { + val keys = OpenIdTestKeys() + val malformedHeader = "Bearer invalid@" + "x".repeat(160) + + captureProviderLogs("auth0", ch.qos.logback.classic.Level.TRACE).use { logs -> + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val provider = oidc.provider("auth0") { + issuer = ISSUER_URL + jwt { + jwkProviderFactory = { keys.jwkProvider } + } + accessToken { + audiences = setOf("api") + } + bearer() + } + + routing { + authenticateWith(provider.bearer) { + get("/protected") { + call.respondText("ok") + } + } + } + } + + val response = client.get("/protected") { + header(HttpHeaders.Authorization, malformedHeader) + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + val logEvent = assertNotNull( + logs.events.firstOrNull { + it.formattedMessage.contains("Malformed OpenID Connect Authorization header ignored") + } + ) + assertContains(logEvent.formattedMessage, "Bearer invalid@") + assertContains(logEvent.formattedMessage, "...") + assertTrue(!logEvent.formattedMessage.contains("x".repeat(120))) + } + } + + @Test + fun `verifyAccessToken normalizes malformed jwt rejection`() = testApplication { + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val provider = oidc.provider("auth0") { + issuer = ISSUER_URL + accessToken { + audiences = setOf("api") + } + } + + routing { + get("/verify") { + val failure = try { + provider.verifyAccessToken("not-a-jwt-with-secret") + null + } catch (cause: Throwable) { + cause + } + assertIs(failure) + call.respondText(failure.message.orEmpty()) + } + } + } + + val response = client.get("/verify") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("The token was expected to have 3 parts, but got 0.", response.bodyAsText()) + } + + @Test + fun `transformPrincipal exposes typed application principal`() = testApplication { + val keys = OpenIdTestKeys() + + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + + val google = oidc.provider( + name = "google", + transformPrincipal = { p -> + val accessToken = p as? OidcToken.Access + accessToken?.userInfo?.subject?.let(::UserIdPrincipal) + } + ) { + issuer = ISSUER_URL + jwt { + jwkProviderFactory = { keys.jwkProvider } + } + accessToken { + audiences = setOf("api") + } + bearer() + } + + routing { + authenticateWith(google.bearer) { + get("/typed") { + call.respondText(principal.name) + } + } + } + } + + val response = client.get("/typed") { + header(HttpHeaders.Authorization, "Bearer ${keys.token(audience = "api", subject = "typed-user")}") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("typed-user", response.bodyAsText()) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt new file mode 100644 index 00000000000..8e6fd625051 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt @@ -0,0 +1,78 @@ +/* + * 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.server.auth.oidc.utils.* +import io.ktor.server.testing.* +import io.ktor.utils.io.* +import kotlin.test.* +import kotlin.time.Duration.Companion.ZERO + +class OidcConfigValidationTest { + + @Test + fun `bearer config validates required access token settings`() { + val bearerFailure = assertProviderValidationFails { bearer() } + assertContains(bearerFailure.message.orEmpty(), "accessToken") + assertContains(bearerFailure.message.orEmpty(), "audiences") + } + + @Test + fun `jwkProviderFactory cannot be combined with cache or rate-limit config`() { + val configurations: List Unit>> = listOf( + "jwkCache" to { jwkCache() }, + "disableJwkCache" to { disableJwkCache() }, + "jwkRateLimit" to { jwkRateLimit() }, + "disableJwkRateLimit" to { disableJwkRateLimit() }, + ) + + configurations.forEach { (name, configureJwt) -> + val failure = assertProviderValidationFails { + jwt { + jwkProviderFactory = { error("not used") } + configureJwt() + } + } + assertContains(failure.message.orEmpty(), "jwkProviderFactory", message = name) + assertContains(failure.message.orEmpty(), "jwkCache or jwkRateLimit", message = name) + } + } + + @Test + fun `bearer token source defaults to authorization header unless customized`() { + OidcProviderConfig("default", OidcToken::class).apply { + bearer() + assertNull(bearerConfig!!.tokenExtractor) + } + OidcProviderConfig("custom", OidcToken::class).apply { + bearer { + tokenExtractor = { call -> call.request.headers["X-Token"] } + } + assertNotNull(bearerConfig!!.tokenExtractor) + } + } + + private fun assertProviderValidationFails( + configure: OidcProviderConfig.() -> Unit, + ): Throwable = assertFailsWith { + testApplication { + openIdProvider() + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + oidc.provider("auth0") { + issuer = ISSUER_URL + configure() + } + } + startApplication() + } + } +} 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 index 5434eb5387d..ce8242f2dfe 100644 --- 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 @@ -28,7 +28,7 @@ class OidcDiscoveryTest { refreshingOpenIdProvider(discoveryRequests, allowRefreshResponse) val openIdClient = openIdHttpClient() - lateinit var provider: OidcProvider + lateinit var provider: OidcProvider application { val oidc = openIdConnect { httpClient = openIdClient @@ -89,7 +89,7 @@ class OidcDiscoveryTest { } val openIdClient = openIdHttpClient() - lateinit var provider: OidcProvider + lateinit var provider: OidcProvider application { monitor.subscribe(OidcMetadataRefreshFailed) { failure -> refreshFailed.complete(failure) @@ -161,6 +161,7 @@ class OidcDiscoveryTest { testApplication { externalServices { hosts(ISSUER_URL) { + installDiscoveryContentNegotiation() routing { get("/.well-known/openid-configuration") { discoveryRequests.incrementAndGet() 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 index e0eee1515d9..8ae51ec43b7 100644 --- 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 @@ -55,7 +55,7 @@ class OidcEnvironmentConfigTest { openIdProvider() val openIdClient = openIdHttpClient() - lateinit var provider: OidcProvider + lateinit var provider: OidcProvider application { val oidc = openIdConnect { httpClient = openIdClient @@ -90,7 +90,7 @@ class OidcEnvironmentConfigTest { } val openIdClient = openIdHttpClient() - lateinit var provider: OidcProvider + lateinit var provider: OidcProvider application { val oidc = openIdConnect { httpClient = openIdClient diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwkProviderConfigTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwkProviderConfigTest.kt new file mode 100644 index 00000000000..3a649ffe012 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwkProviderConfigTest.kt @@ -0,0 +1,244 @@ +/* + * 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 com.auth0.jwk.JwkProvider +import com.auth0.jwk.RateLimitReachedException +import com.auth0.jwk.SigningKeyNotFoundException +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlinx.coroutines.test.runTest +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.* +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +class OidcJwkProviderConfigTest { + + @Test + fun `cache config caches fetched keys`() { + val keys = OpenIdTestKeys() + + withJwksServer(keys) { jwksUri, fetchCount -> + val jwkProvider = oidcJwkProvider(jwksUri) { + jwkCache(maxEntries = 1, duration = 1.hours) + } + + assertEquals(keys.jwk.id, jwkProvider.get(keys.jwk.id).id) + assertEquals(keys.jwk.id, jwkProvider.get(keys.jwk.id).id) + assertEquals(1, fetchCount.get()) + } + } + + @Test + fun `disableCache lets repeated key lookups reach the rate limiter`() { + val keys = OpenIdTestKeys() + + withJwksServer(keys) { jwksUri, fetchCount -> + val jwkProvider = oidcJwkProvider(jwksUri) { + disableJwkCache() + jwkRateLimit(bucketSize = 1, refillDuration = 1.hours) + } + + assertEquals(keys.jwk.id, jwkProvider.get(keys.jwk.id).id) + assertFailsWith { + jwkProvider.get(keys.jwk.id) + } + assertEquals(1, fetchCount.get()) + } + } + + @Test + fun `rateLimit limits repeated unknown-key lookups`() { + val keys = OpenIdTestKeys() + + withJwksServer(keys) { jwksUri, fetchCount -> + val jwkProvider = oidcJwkProvider(jwksUri) { + disableJwkCache() + jwkRateLimit(bucketSize = 1, refillDuration = 1.hours) + } + + assertFailsWith { + jwkProvider.get("missing-1") + } + assertFailsWith { + jwkProvider.get("missing-2") + } + assertEquals(2, fetchCount.get()) + } + } + + @Test + fun `disableRateLimit allows repeated unknown-key lookups`() { + val keys = OpenIdTestKeys() + + withJwksServer(keys) { jwksUri, fetchCount -> + val jwkProvider = oidcJwkProvider(jwksUri) { + disableJwkCache() + disableJwkRateLimit() + } + + repeat(12) { index -> + assertFailsWith { + jwkProvider.get("missing-$index") + } + } + assertEquals(13, fetchCount.get()) + } + } + + @Test + fun `jwkBuilder remains the final low-level override`() { + val keys = OpenIdTestKeys() + + withJwksServer(keys) { jwksUri, fetchCount -> + val jwkProvider = oidcJwkProvider(jwksUri) { + jwkCache(maxEntries = 1, duration = 1.hours) + jwkRateLimit(bucketSize = 1, refillDuration = 1.hours) + jwkBuilder = { + cached(false) + } + } + + assertEquals(keys.jwk.id, jwkProvider.get(keys.jwk.id).id) + assertFailsWith { + jwkProvider.get(keys.jwk.id) + } + assertEquals(1, fetchCount.get()) + } + } + + @Test + fun `default provider is reused while jwks uri is unchanged`() = testApplication { + val provider = OidcProvider( + name = "auth0", + client = client, + config = OidcProviderConfig("auth0", OidcToken::class).apply { + issuer = ISSUER_URL + }, + ) + val initialMetadata = metadata(jwksUri = "http://127.0.0.1:1/jwks") + provider.updateMetadata(initialMetadata) + val initialJwkProvider = provider.currentJwkProvider() + + provider.updateMetadata( + metadata( + authorizationEndpoint = "$ISSUER_URL/authorize-updated", + jwksUri = initialMetadata.jwksUri, + ) + ) + assertSame(initialJwkProvider, provider.currentJwkProvider()) + + provider.updateMetadata( + metadata(jwksUri = "http://127.0.0.1:1/jwks-updated") + ) + assertNotSame(initialJwkProvider, provider.currentJwkProvider()) + } + + @Test + fun `jwk cache and rate-limit config validates positive values`() { + assertFailsWith { + OidcJwtConfig().jwkCache(maxEntries = 0) + } + assertFailsWith { + OidcJwtConfig().jwkCache(duration = 0.seconds) + } + assertFailsWith { + OidcJwtConfig().jwkRateLimit(bucketSize = 0) + } + assertFailsWith { + OidcJwtConfig().jwkRateLimit(refillDuration = 0.seconds) + } + } + + private fun withJwksServer( + keys: OpenIdTestKeys, + block: suspend (jwksUri: String, fetchCount: AtomicInteger) -> Unit, + ) = runTest { + val fetchCount = AtomicInteger() + val port = ServerSocket(0).use { it.localPort } + val server = embeddedServer(CIO, port = port) { + routing { + get("/jwks") { + fetchCount.incrementAndGet() + call.respondText( + text = keys.jwksJson(), + status = HttpStatusCode.OK, + contentType = ContentType.Application.Json, + ) + } + } + }.start(wait = false) + + try { + block("http://127.0.0.1:$port/jwks", fetchCount) + } finally { + server.stopSuspend(gracePeriodMillis = 10, timeoutMillis = 10) + } + } + + private fun oidcJwkProvider( + jwksUri: String, + configureJwt: OidcJwtConfig.() -> Unit, + ): JwkProvider { + val client = HttpClient(MockEngine) { + engine { + addHandler { respondOk() } + } + } + val provider = try { + OidcProvider( + name = "auth0", + client = client, + config = OidcProviderConfig("auth0", OidcToken::class).apply { + issuer = ISSUER_URL + jwt(configureJwt) + validate() + }, + ).apply { + updateMetadata(metadata(jwksUri = jwksUri)) + } + } finally { + client.close() + } + return provider.currentJwkProvider() + } + + private fun metadata( + authorizationEndpoint: String = "$ISSUER_URL/authorize", + jwksUri: String, + ): OpenIdProviderMetadata = OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = authorizationEndpoint, + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = jwksUri, + ) + + private fun OpenIdTestKeys.jwksJson(): String { + val jwk = this.jwk + return """ + { + "keys": [ + { + "kid": "${jwk.id}", + "kty": "${jwk.type}", + "alg": "${jwk.algorithm}", + "use": "${jwk.usage}", + "n": "${jwk.additionalAttributes["n"]}", + "e": "${jwk.additionalAttributes["e"]}" + } + ] + } + """.trimIndent() + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwtKeyAndAlgorithmTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwtKeyAndAlgorithmTest.kt new file mode 100644 index 00000000000..07275776967 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcJwtKeyAndAlgorithmTest.kt @@ -0,0 +1,140 @@ +/* + * 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.http.auth.* +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.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.ZERO + +class OidcJwtKeyAndAlgorithmTest { + + @Test + fun `bearer authentication accepts JWT without kid when JWKS key verifies`() = testApplication { + val keys = OpenIdTestKeys() + openIdProvider() + installJwtBearer(keys = keys) + + val response = client.get("/protected") { + header( + HttpHeaders.Authorization, + "Bearer ${keys.token(audience = "api", subject = "missing-kid", keyId = null)}" + ) + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("missing-kid", response.bodyAsText()) + } + + @Test + fun `bearer authentication rejects JWT without kid when JWKS has multiple keys`() = testApplication { + val keys = OpenIdTestKeys("kid-1") + val otherKeys = OpenIdTestKeys("kid-2") + + openIdProvider() + installJwtBearer(jwkProviderFactory = { jwkProviderWithMultipleKeys(keys, otherKeys) }) + + val response = client.get("/protected") { + header( + HttpHeaders.Authorization, + "Bearer ${keys.token(audience = "api", subject = "missing-kid", keyId = null)}" + ) + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `bearer authentication rejects JWT when JWK operations do not allow verification`() = testApplication { + val keys = OpenIdTestKeys() + + openIdProvider() + installJwtBearer(jwkProviderFactory = { jwkProviderWithoutVerifyOperation(keys) }) + + val response = client.get("/protected") { + header(HttpHeaders.Authorization, "Bearer ${keys.token(audience = "api", subject = "key-ops")}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `access token algorithms ignore discovery id token allow list by default`() = testApplication { + val keys = OpenIdTestKeys() + openIdProvider( + OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = "$ISSUER_URL/authorize", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks", + idTokenSigningAlgValuesSupported = listOf(SignatureAlgorithm.RSA_SHA_384.testJwaName), + ) + ) + installJwtBearer(keys = keys) + + val response = client.get("/protected") { + header(HttpHeaders.Authorization, "Bearer ${keys.token(audience = "api", subject = "alg-user")}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `access token algorithms honor explicit allow list`() = testApplication { + val keys = OpenIdTestKeys() + + openIdProvider() + installJwtBearer( + keys = keys, + configureJwt = { allowedAlgorithms = setOf(SignatureAlgorithm.RSA_SHA_384) }, + ) + + val response = client.get("/protected") { + header(HttpHeaders.Authorization, "Bearer ${keys.token(audience = "api", subject = "alg-user")}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + private fun ApplicationTestBuilder.installJwtBearer( + keys: OpenIdTestKeys? = null, + jwkProviderFactory: ((String) -> com.auth0.jwk.JwkProvider)? = null, + configureJwt: OidcJwtConfig.() -> Unit = {}, + ) { + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val provider = oidc.provider("auth0") { + issuer = ISSUER_URL + jwt { + configureJwt() + this.jwkProviderFactory = jwkProviderFactory ?: { checkNotNull(keys).jwkProvider } + } + accessToken { + audiences = setOf("api") + } + bearer() + } + + routing { + authenticateWith(provider.bearer) { + get("/protected") { + val accessToken = principal as OidcToken.Access + call.respondText(accessToken.userInfo?.subject ?: "ok") + } + } + } + } + } +} 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 index a0d5fc32a06..1f5fc07a9ce 100644 --- 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 @@ -2,12 +2,18 @@ * 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 com.auth0.jwk.Jwk +import com.auth0.jwk.JwkProvider +import io.ktor.server.auth.* import io.ktor.server.auth.oidc.utils.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.testing.* +import io.ktor.utils.io.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -19,16 +25,27 @@ class OidcPluginRegistrationTest { @Test fun `plugin and provider helpers expose expected state`() = testApplication { + val providers = mutableListOf() val provider = OidcProvider( name = "auth0", client = client, - config = OidcProviderConfig("auth0").apply { + config = OidcProviderConfig("auth0", OidcToken::class).apply { issuer = ISSUER_URL + jwt { + jwkProviderFactory = { + // don't convert to lambda because compiler would reuse the same instance every time + object : JwkProvider { + override fun get(keyId: String?): Jwk? { + error("JWK lookup is not used in this test") + } + }.also(providers::add) + } + } }, ) provider.updateMetadata(openIdProviderMetadata) - assertEquals(openIdProviderMetadata, provider.currentMetadata()) + val initialJwkProvider = provider.currentJwkProvider() val updatedMetadata = OpenIdProviderMetadata( issuer = ISSUER_URL, @@ -38,6 +55,28 @@ class OidcPluginRegistrationTest { ) provider.updateMetadata(updatedMetadata) assertEquals(updatedMetadata, provider.currentMetadata()) + assertSame(initialJwkProvider, provider.currentJwkProvider()) + assertEquals(1, providers.size) + + provider.updateMetadata( + OpenIdProviderMetadata( + issuer = ISSUER_URL, + authorizationEndpoint = "$ISSUER_URL/authorize-updated", + tokenEndpoint = "$ISSUER_URL/token", + jwksUri = "$ISSUER_URL/jwks-updated", + ) + ) + assertNotSame(initialJwkProvider, provider.currentJwkProvider()) + assertEquals(2, providers.size) + + val providerWithoutSchemes = OidcProvider( + name = "auth0", + client = client, + config = OidcProviderConfig("auth0", OidcToken::class).apply { + issuer = ISSUER_URL + }, + ) + assertFailsWith { providerWithoutSchemes.bearer } application { val installed: Oidc = openIdConnect { } @@ -51,7 +90,12 @@ class OidcPluginRegistrationTest { assertConcurrentDuplicateRegistrations( providerNames = List(16) { "auth0" }, expectedFailureMessage = "already configured", - ) + ) { + accessToken { + audiences = setOf("api") + } + bearer() + } assertConcurrentDuplicateRegistrations( providerNames = List(16) { index -> "auth0-$index" }, @@ -62,7 +106,7 @@ class OidcPluginRegistrationTest { } @Test - fun `provider registration validates names and duplicate providers`() { + fun `provider registration validates names and duplicate typed providers`() { val invalidNames = listOf("Google", "google_auth", "-google", "google-", "google--auth") invalidNames.forEach { providerName -> val failure = assertFailsWith { @@ -91,7 +135,20 @@ class OidcPluginRegistrationTest { httpClient = openIdClient discoveryRefreshInterval = ZERO } - oidc.provider("auth0") { + oidc.provider( + name = "auth0", + transformPrincipal = { principal -> + when (principal) { + is OidcToken.Id -> UserIdPrincipal(principal.userInfo.subject) + is OidcToken.Access -> { + principal.userInfo?.subject?.let(::UserIdPrincipal) + } + is OidcToken.Opaque -> { + principal.introspection.subject?.let(::UserIdPrincipal) + } + } + } + ) { issuer = ISSUER_URL } @@ -108,6 +165,7 @@ class OidcPluginRegistrationTest { private fun assertConcurrentDuplicateRegistrations( providerNames: List, expectedFailureMessage: String, + configureProvider: OidcProviderConfig.() -> Unit = {}, ) = testApplication { val discoveryRequests = AtomicInteger() @@ -136,6 +194,7 @@ class OidcPluginRegistrationTest { runCatching { oidc.provider(providerName) { issuer = ISSUER_URL + configureProvider() } } } @@ -175,6 +234,10 @@ class OidcPluginRegistrationTest { async { oidc.provider(name) { this.issuer = issuer + accessToken { + audiences = setOf("api") + } + bearer() } } }.awaitAll() diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestKeys.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestKeys.kt new file mode 100644 index 00000000000..f3d00f0d256 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestKeys.kt @@ -0,0 +1,152 @@ +/* + * 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 com.auth0.jwk.Jwk +import com.auth0.jwk.JwkProvider +import com.auth0.jwk.SigningKeyNotFoundException +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.http.auth.* +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.* + +internal class OpenIdTestKeys( + private val keyId: String = "kid-1", + jwkAlgorithm: SignatureAlgorithm? = SignatureAlgorithm.RSA_SHA_256, +) { + private val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + private val publicKey = keyPair.public as RSAPublicKey + private val privateKey = keyPair.private as RSAPrivateKey + + val jwk: Jwk = Jwk.fromValues(rsaJwkValues(publicKey, keyId, jwkAlgorithm)) + + val jwkProvider: JwkProvider = JwkProvider { requestedKeyId -> + require(requestedKeyId == null || requestedKeyId == keyId) { "Unexpected key id $requestedKeyId" } + jwk + } + + fun token( + audience: String, + subject: String? = "ktor-user", + issuer: String = ISSUER_URL, + keyId: String? = this.keyId, + nonce: String? = null, + name: String? = null, + email: String? = null, + clientId: String? = null, + expiresAt: Date? = null, + atHash: String? = null, + algorithm: SignatureAlgorithm = SignatureAlgorithm.RSA_SHA_256, + headerClaims: Map = emptyMap(), + ): String { + val builder = JWT.create() + .withIssuer(issuer) + .withAudience(audience) + + if (headerClaims.isNotEmpty()) { + builder.withHeader(headerClaims) + } + subject?.let { builder.withSubject(it) } + keyId?.let { builder.withKeyId(it) } + nonce?.let { builder.withClaim("nonce", it) } + name?.let { builder.withClaim("name", it) } + email?.let { builder.withClaim("email", it) } + clientId?.let { builder.withClaim("client_id", it) } + expiresAt?.let { builder.withExpiresAt(it) } + atHash?.let { builder.withClaim("at_hash", it) } + + return builder.sign(signingAlgorithm(algorithm)) + } + + fun hmacToken( + audience: String, + subject: String = "ktor-user", + issuer: String = ISSUER_URL, + ): String = JWT.create() + .withIssuer(issuer) + .withAudience(audience) + .withSubject(subject) + .sign(Algorithm.HMAC256("secret")) + + fun unsignedToken( + audience: String, + subject: String = "ktor-user", + issuer: String = ISSUER_URL, + ): String = JWT.create() + .withIssuer(issuer) + .withAudience(audience) + .withSubject(subject) + .sign(Algorithm.none()) + + private fun signingAlgorithm(algorithm: SignatureAlgorithm): Algorithm = + when (algorithm) { + SignatureAlgorithm.RSA_SHA_256 -> Algorithm.RSA256(publicKey, privateKey) + SignatureAlgorithm.RSA_SHA_384 -> Algorithm.RSA384(publicKey, privateKey) + SignatureAlgorithm.RSA_SHA_512 -> Algorithm.RSA512(publicKey, privateKey) + else -> error("Unsupported test signing algorithm: ${algorithm.jwaName ?: algorithm.name}") + } +} + +internal val SignatureAlgorithm.testJwaName: String + get() = checkNotNull(jwaName) { "Test signing algorithm $name has no JWA name" } + +internal fun testAtHash( + accessToken: String, + algorithm: SignatureAlgorithm = SignatureAlgorithm.RSA_SHA_256, +): String { + val digest = algorithm.digestAlgorithm.toDigester().digest(accessToken.toByteArray(Charsets.US_ASCII)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest.copyOfRange(0, digest.size / 2)) +} + +internal fun jwkProviderWithMultipleKeys(vararg keys: OpenIdTestKeys): JwkProvider = + JwkProvider { requestedKeyId -> + if (requestedKeyId == null) { + throw SigningKeyNotFoundException("Multiple keys are available", null) + } + keys.firstOrNull { it.jwk.id == requestedKeyId }?.jwk + ?: throw SigningKeyNotFoundException("No key found for kid $requestedKeyId", null) + } + +internal fun jwkProviderWithoutVerifyOperation(keys: OpenIdTestKeys): JwkProvider { + val values = keys.jwk.additionalAttributes.toMutableMap() + values["kid"] = keys.jwk.id + values["kty"] = keys.jwk.type + values["alg"] = keys.jwk.algorithm + values["use"] = keys.jwk.usage + values["key_ops"] = listOf("sign") + val jwk = Jwk.fromValues(values) + return JwkProvider { requestedKeyId -> + if (requestedKeyId == null || requestedKeyId == jwk.id) { + jwk + } else { + throw SigningKeyNotFoundException("No key found for kid $requestedKeyId", null) + } + } +} + +private fun rsaJwkValues( + publicKey: RSAPublicKey, + keyId: String, + algorithm: SignatureAlgorithm? = SignatureAlgorithm.RSA_SHA_256, +): Map = + mutableMapOf( + "kid" to keyId, + "kty" to "RSA", + "use" to "sig", + "n" to Base64.getUrlEncoder().withoutPadding().encodeToString( + publicKey.modulus.toByteArray().stripLeadingZero() + ), + "e" to Base64.getUrlEncoder().withoutPadding().encodeToString( + publicKey.publicExponent.toByteArray().stripLeadingZero() + ), + ).apply { + algorithm?.let { put("alg", it.testJwaName) } + } + +private fun ByteArray.stripLeadingZero(): ByteArray = + if (size > 1 && this[0] == 0.toByte()) copyOfRange(1, size) else this diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestLogging.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestLogging.kt new file mode 100644 index 00000000000..836a80f2fc9 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestLogging.kt @@ -0,0 +1,38 @@ +/* + * 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 ch.qos.logback.classic.Level +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory + +internal class CapturedLogEvents( + private val logger: ch.qos.logback.classic.Logger, + level: Level, +) : AutoCloseable { + private val previousLevel = logger.level + private val appender = ListAppender().apply { start() } + + val events: List + get() = appender.list + + init { + logger.level = level + logger.addAppender(appender) + } + + override fun close() { + logger.detachAppender(appender) + logger.level = previousLevel + } +} + +internal fun captureProviderLogs(providerName: String, level: Level): CapturedLogEvents { + val logger = LoggerFactory.getLogger( + "io.ktor.server.auth.oidc.OidcProvider[$providerName]" + ) as ch.qos.logback.classic.Logger + return CapturedLogEvents(logger, level) +}